AI-admin: extra_params per provider (web-plugin, custom params)

- Ny kolonne extra_params JSONB på ai_model_providers (migrasjon 0009)
- Web-søk toggle-pill per provider — ett klikk for å slå på/av
- «...»-knapp åpner JSON-editor for vilkårlige extra_params
- Config-generering fletter extra_params inn i litellm_params
- POST/PATCH provider-endepunkter støtter extra_params

Eksempel: Grok med web-plugin genererer:
  plugins: [{"id":"web"}]
under litellm_params i config.yaml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 06:11:54 +01:00
parent ad0d4f1f8a
commit 35c76a7038
6 changed files with 202 additions and 8 deletions

View file

@ -0,0 +1,13 @@
-- 0009_provider_extra_params.sql
-- Legger til extra_params JSONB på ai_model_providers for leverandør-spesifikke
-- parametere (f.eks. OpenRouter web-plugin, domenefilter).
BEGIN;
ALTER TABLE ai_model_providers
ADD COLUMN extra_params JSONB;
COMMENT ON COLUMN ai_model_providers.extra_params IS
'Valgfrie leverandør-spesifikke params som flettes inn i litellm_params ved config-generering. Eksempel: {"plugins": [{"id": "web"}]}';
COMMIT;

View file

@ -17,7 +17,8 @@ export const POST: RequestHandler = async ({ locals, url }) => {
SELECT SELECT
a.alias AS model_name, a.alias AS model_name,
p.litellm_model, p.litellm_model,
p.api_key_env p.api_key_env,
p.extra_params
FROM ai_model_aliases a FROM ai_model_aliases a
JOIN ai_model_providers p ON p.alias_id = a.id JOIN ai_model_providers p ON p.alias_id = a.id
WHERE a.is_active = true AND p.is_active = true WHERE a.is_active = true AND p.is_active = true
@ -35,6 +36,12 @@ export const POST: RequestHandler = async ({ locals, url }) => {
yaml += ` litellm_params:\n`; yaml += ` litellm_params:\n`;
yaml += ` model: "${row.litellm_model}"\n`; yaml += ` model: "${row.litellm_model}"\n`;
yaml += ` api_key: "os.environ/${row.api_key_env}"\n`; yaml += ` api_key: "os.environ/${row.api_key_env}"\n`;
// Flett inn extra_params som ekstra nøkler under litellm_params
if (row.extra_params && typeof row.extra_params === 'object') {
for (const [key, value] of Object.entries(row.extra_params)) {
yaml += ` ${key}: ${JSON.stringify(value)}\n`;
}
}
} }
yaml += '\nrouter_settings:\n'; yaml += '\nrouter_settings:\n';

View file

@ -6,15 +6,15 @@ import { sql } from '$lib/server/db';
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.workspace || !locals.user) error(401); if (!locals.workspace || !locals.user) error(401);
const { alias_id, priority, litellm_model, api_key_env } = await request.json(); const { alias_id, priority, litellm_model, api_key_env, extra_params } = await request.json();
if (!alias_id || !litellm_model || !api_key_env) { if (!alias_id || !litellm_model || !api_key_env) {
error(400, 'alias_id, litellm_model og api_key_env er påkrevd'); error(400, 'alias_id, litellm_model og api_key_env er påkrevd');
} }
const [row] = await sql` const [row] = await sql`
INSERT INTO ai_model_providers (alias_id, priority, litellm_model, api_key_env) INSERT INTO ai_model_providers (alias_id, priority, litellm_model, api_key_env, extra_params)
VALUES (${alias_id}::uuid, ${priority ?? 99}, ${litellm_model}, ${api_key_env}) VALUES (${alias_id}::uuid, ${priority ?? 99}, ${litellm_model}, ${api_key_env}, ${extra_params ? sql.json(extra_params) : null})
RETURNING id, alias_id, priority, litellm_model, api_key_env, is_active RETURNING id, alias_id, priority, litellm_model, api_key_env, is_active, extra_params
`; `;
return json(row, { status: 201 }); return json(row, { status: 201 });

View file

@ -8,15 +8,20 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const body = await request.json(); const body = await request.json();
const extraParams = 'extra_params' in body
? (body.extra_params ? sql.json(body.extra_params) : null)
: undefined;
const [row] = await sql` const [row] = await sql`
UPDATE ai_model_providers SET UPDATE ai_model_providers SET
priority = COALESCE(${body.priority ?? null}, priority), priority = COALESCE(${body.priority ?? null}, priority),
litellm_model = COALESCE(${body.litellm_model ?? null}, litellm_model), litellm_model = COALESCE(${body.litellm_model ?? null}, litellm_model),
api_key_env = COALESCE(${body.api_key_env ?? null}, api_key_env), api_key_env = COALESCE(${body.api_key_env ?? null}, api_key_env),
is_active = COALESCE(${body.is_active ?? null}, is_active), is_active = COALESCE(${body.is_active ?? null}, is_active),
extra_params = ${extraParams !== undefined ? extraParams : sql`extra_params`},
updated_at = now() updated_at = now()
WHERE id = ${params.id}::uuid WHERE id = ${params.id}::uuid
RETURNING id, alias_id, priority, litellm_model, api_key_env, is_active RETURNING id, alias_id, priority, litellm_model, api_key_env, is_active, extra_params
`; `;
if (!row) error(404, 'Provider ikke funnet'); if (!row) error(404, 'Provider ikke funnet');

View file

@ -9,7 +9,7 @@ export const load: PageServerLoad = async () => {
`; `;
const providers = await sql` const providers = await sql`
SELECT id, alias_id, priority, litellm_model, api_key_env, is_active SELECT id, alias_id, priority, litellm_model, api_key_env, is_active, extra_params
FROM ai_model_providers FROM ai_model_providers
ORDER BY alias_id, priority ORDER BY alias_id, priority
`; `;

View file

@ -17,6 +17,7 @@
litellm_model: string; litellm_model: string;
api_key_env: string; api_key_env: string;
is_active: boolean; is_active: boolean;
extra_params: Record<string, unknown> | null;
} }
interface Route { interface Route {
@ -94,6 +95,10 @@
let showCatalogPicker = $state(false); let showCatalogPicker = $state(false);
let catalogPickerSearch = $state(''); let catalogPickerSearch = $state('');
// Extra params redigering
let editingExtraParams = $state<string | null>(null);
let editExtraParamsText = $state('');
// Ny provider-form // Ny provider-form
let newProvider = $state<{ alias_id: string; litellm_model: string; api_key_env: string }>({ let newProvider = $state<{ alias_id: string; litellm_model: string; api_key_env: string }>({
alias_id: '', alias_id: '',
@ -314,6 +319,66 @@
return hasAny ? total : null; return hasAny ? total : null;
}); });
function hasWebPlugin(provider: Provider): boolean {
const plugins = provider.extra_params?.plugins;
return Array.isArray(plugins) && plugins.some((p: any) => p.id === 'web');
}
async function toggleWebPlugin(provider: Provider) {
const has = hasWebPlugin(provider);
let newParams: Record<string, unknown> | null;
if (has) {
const { plugins, ...rest } = provider.extra_params ?? {};
newParams = Object.keys(rest).length > 0 ? rest : null;
} else {
newParams = { ...(provider.extra_params ?? {}), plugins: [{ id: 'web' }] };
}
saving = provider.id;
errorMsg = '';
try {
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extra_params: newParams })
});
if (!res.ok) throw new Error('Feil');
const updated = await res.json();
provider.extra_params = updated.extra_params;
markSaved(provider.id);
} catch {
errorMsg = 'Kunne ikke oppdatere web-plugin';
} finally {
saving = null;
}
}
function startEditExtraParams(provider: Provider) {
editingExtraParams = provider.id;
editExtraParamsText = provider.extra_params ? JSON.stringify(provider.extra_params, null, 2) : '';
}
async function saveExtraParams(provider: Provider) {
saving = provider.id;
errorMsg = '';
try {
const parsed = editExtraParamsText.trim() ? JSON.parse(editExtraParamsText) : null;
const res = await fetch(`/api/admin/ai/providers/${provider.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extra_params: parsed })
});
if (!res.ok) throw new Error('Feil');
const updated = await res.json();
provider.extra_params = updated.extra_params;
editingExtraParams = null;
markSaved(provider.id);
} catch (e: any) {
errorMsg = e.message === 'Feil' ? 'Kunne ikke lagre params' : `Ugyldig JSON: ${e.message}`;
} finally {
saving = null;
}
}
async function toggleProvider(provider: Provider) { async function toggleProvider(provider: Provider) {
saving = provider.id; saving = provider.id;
errorMsg = ''; errorMsg = '';
@ -650,6 +715,18 @@
<span class="col-pri">#{provider.priority}</span> <span class="col-pri">#{provider.priority}</span>
<span class="col-model">{provider.litellm_model}</span> <span class="col-model">{provider.litellm_model}</span>
<span class="col-key">{provider.api_key_env}</span> <span class="col-key">{provider.api_key_env}</span>
<span class="col-extra-pills">
<button
class="extra-pill"
class:extra-pill--on={hasWebPlugin(provider)}
onclick={() => toggleWebPlugin(provider)}
title="Web-søk (OpenRouter plugin)"
>web {hasWebPlugin(provider) ? '\u2713' : '\u2717'}</button>
{#if provider.extra_params && Object.keys(provider.extra_params).length > (hasWebPlugin(provider) ? 1 : 0)}
<span class="extra-pill extra-pill--custom" title={JSON.stringify(provider.extra_params)}>+params</span>
{/if}
<button class="extra-edit-btn" onclick={() => startEditExtraParams(provider)} title="Rediger extra_params JSON">&hellip;</button>
</span>
<span class="col-active"> <span class="col-active">
<button class="toggle-btn" onclick={() => toggleProvider(provider)}> <button class="toggle-btn" onclick={() => toggleProvider(provider)}>
{provider.is_active ? 'På' : 'Av'} {provider.is_active ? 'På' : 'Av'}
@ -664,6 +741,19 @@
{/if} {/if}
</span> </span>
</div> </div>
{#if editingExtraParams === provider.id}
<div class="extra-params-editor">
<textarea
bind:value={editExtraParamsText}
rows="4"
placeholder={'{"plugins": [{"id": "web"}]}'}
></textarea>
<div class="extra-params-actions">
<button class="add-btn" onclick={() => saveExtraParams(provider)}>Lagre</button>
<button class="toggle-btn" onclick={() => { editingExtraParams = null; }}>Avbryt</button>
</div>
</div>
{/if}
{/each} {/each}
<div class="provider-row-alias provider-row--add"> <div class="provider-row-alias provider-row--add">
@ -1192,7 +1282,7 @@
.provider-row-alias { .provider-row-alias {
display: grid; display: grid;
grid-template-columns: 30px 2fr 1.5fr 60px 60px 40px; grid-template-columns: 30px 2fr 1fr auto 50px 50px 40px;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.35rem 0; padding: 0.35rem 0;
@ -1290,6 +1380,85 @@
margin-left: 0.5rem; margin-left: 0.5rem;
} }
/* Extra params */
.col-extra-pills {
display: flex;
gap: 0.25rem;
align-items: center;
}
.extra-pill {
font-size: 0.6rem;
padding: 0.1rem 0.4rem;
border-radius: 9999px;
border: 1px solid #2d3148;
background: #1a1d2e;
color: #8b92a5;
cursor: pointer;
white-space: nowrap;
}
.extra-pill:hover {
border-color: #3b82f6;
}
.extra-pill--on {
background: #0d3320;
border-color: #166534;
color: #4ade80;
}
.extra-pill--custom {
background: #2d1f4e;
border-color: #6d4aaa;
color: #c4b5fd;
cursor: default;
}
.extra-edit-btn {
background: none;
border: none;
color: #8b92a5;
font-size: 0.75rem;
cursor: pointer;
padding: 0 0.2rem;
}
.extra-edit-btn:hover {
color: #e1e4e8;
}
.extra-params-editor {
background: #161822;
border: 1px solid #3b82f6;
border-radius: 6px;
padding: 0.5rem;
margin: 0.35rem 0;
}
.extra-params-editor textarea {
width: 100%;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.4rem;
font-size: 0.75rem;
font-family: monospace;
resize: vertical;
}
.extra-params-editor textarea:focus {
outline: none;
border-color: #3b82f6;
}
.extra-params-actions {
display: flex;
gap: 0.35rem;
margin-top: 0.35rem;
}
/* Buttons */ /* Buttons */
.toggle-btn { .toggle-btn {
background: #1a1d2e; background: #1a1d2e;