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:
parent
ad0d4f1f8a
commit
35c76a7038
6 changed files with 202 additions and 8 deletions
13
migrations/0009_provider_extra_params.sql
Normal file
13
migrations/0009_provider_extra_params.sql
Normal 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;
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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">…</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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue