From b1a7e55fffa3cb9db31ff708a2f4f7659e67ac3a Mon Sep 17 00:00:00 2001 From: vegard Date: Mon, 16 Mar 2026 05:23:47 +0100 Subject: [PATCH] =?UTF-8?q?AI-admin:=20modellkatalog=20fra=20OpenRouter=20?= =?UTF-8?q?med=20leverand=C3=B8r-akkordion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nytt endepunkt /api/admin/ai/models som proxyer OpenRouter med 1t cache - Nytt endepunkt /api/admin/ai/keys for API-nøkkelstatus - API-nøkkel-pills (GEMINI/OPENROUTER/ANTHROPIC/XAI) øverst på siden - Browsbar modellkatalog gruppert per leverandør i trekkspill-format - Globalt søkefelt, sortert synkende etter pris per leverandør - "Legg til →" fra katalog velger alias og oppretter provider - Katalog-picker i eksisterende add-provider-form Co-Authored-By: Claude Opus 4.6 --- web/src/routes/api/admin/ai/keys/+server.ts | 16 + web/src/routes/api/admin/ai/models/+server.ts | 68 +++ web/src/routes/server-admin/ai/+page.svelte | 565 +++++++++++++++++- 3 files changed, 627 insertions(+), 22 deletions(-) create mode 100644 web/src/routes/api/admin/ai/keys/+server.ts create mode 100644 web/src/routes/api/admin/ai/models/+server.ts diff --git a/web/src/routes/api/admin/ai/keys/+server.ts b/web/src/routes/api/admin/ai/keys/+server.ts new file mode 100644 index 0000000..9fce48d --- /dev/null +++ b/web/src/routes/api/admin/ai/keys/+server.ts @@ -0,0 +1,16 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; + +const KEY_NAMES = ['GEMINI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY', 'XAI_API_KEY']; + +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const keys = KEY_NAMES.map((name) => ({ + name, + configured: !!env[name] + })); + + return json({ keys }); +}; diff --git a/web/src/routes/api/admin/ai/models/+server.ts b/web/src/routes/api/admin/ai/models/+server.ts new file mode 100644 index 0000000..7179a7b --- /dev/null +++ b/web/src/routes/api/admin/ai/models/+server.ts @@ -0,0 +1,68 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; + +interface OpenRouterModel { + id: string; + name: string; + context_length: number; + pricing: { prompt: string; completion: string }; + top_provider?: { max_completion_tokens?: number }; + architecture?: { modality?: string }; +} + +export interface CatalogModel { + id: string; + name: string; + provider: string; + context_length: number; + prompt_price_per_m: number; + completion_price_per_m: number; + modality: string; + max_completion: number | null; +} + +let cache: { models: CatalogModel[]; fetched_at: number } | null = null; +const CACHE_TTL = 60 * 60 * 1000; // 1 time + +function toPerMillion(pricePerToken: string): number { + const n = parseFloat(pricePerToken); + if (isNaN(n)) return 0; + return Math.round(n * 1_000_000 * 100) / 100; +} + +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const apiKey = env.OPENROUTER_API_KEY; + if (!apiKey) { + error(500, 'OPENROUTER_API_KEY er ikke konfigurert'); + } + + if (cache && Date.now() - cache.fetched_at < CACHE_TTL) { + return json(cache.models); + } + + const res = await fetch('https://openrouter.ai/api/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` } + }); + + if (!res.ok) { + error(502, `OpenRouter returnerte ${res.status}`); + } + + const body = await res.json(); + const models: CatalogModel[] = (body.data as OpenRouterModel[]).map((m) => ({ + id: m.id, + name: m.name, + provider: m.id.split('/')[0], + context_length: m.context_length, + prompt_price_per_m: toPerMillion(m.pricing?.prompt ?? '0'), + completion_price_per_m: toPerMillion(m.pricing?.completion ?? '0'), + modality: m.architecture?.modality ?? 'text', + max_completion: m.top_provider?.max_completion_tokens ?? null + })); + + cache = { models, fetched_at: Date.now() }; + return json(models); +}; diff --git a/web/src/routes/server-admin/ai/+page.svelte b/web/src/routes/server-admin/ai/+page.svelte index 9829352..d0f1680 100644 --- a/web/src/routes/server-admin/ai/+page.svelte +++ b/web/src/routes/server-admin/ai/+page.svelte @@ -41,6 +41,22 @@ total_tokens: number; } + interface CatalogModel { + id: string; + name: string; + provider: string; + context_length: number; + prompt_price_per_m: number; + completion_price_per_m: number; + modality: string; + max_completion: number | null; + } + + interface ApiKey { + name: string; + configured: boolean; + } + let aliases = $state(data.aliases as Alias[]); let providers = $state(data.providers as Provider[]); let routing = $state(data.routing as Route[]); @@ -55,6 +71,23 @@ let editPromptText = $state(''); let expandedAlias = $state(null); + // API-nøkler + let apiKeys = $state([]); + let keysLoaded = $state(false); + + // Modellkatalog + let catalogModels = $state([]); + let catalogLoading = $state(false); + let catalogLoaded = $state(false); + let catalogSearch = $state(''); + let expandedProviders = $state>(new Set()); + let addingFromCatalog = $state(null); + let catalogAddAlias = $state(''); + + // Katalog-picker i add-provider-form + let showCatalogPicker = $state(false); + let catalogPickerSearch = $state(''); + // Ny provider-form let newProvider = $state<{ alias_id: string; litellm_model: string; api_key_env: string }>({ alias_id: '', @@ -79,6 +112,124 @@ }, 2000); } + function formatCtx(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(0)}K`; + return String(n); + } + + function formatPrice(n: number): string { + if (n === 0) return 'Gratis'; + return `$${n.toFixed(2)}`; + } + + // API-nøkler + async function loadKeys() { + try { + const res = await fetch('/api/admin/ai/keys'); + if (res.ok) { + const data = await res.json(); + apiKeys = data.keys; + keysLoaded = true; + } + } catch { /* stille */ } + } + loadKeys(); + + // Modellkatalog + async function loadCatalog() { + catalogLoading = true; + errorMsg = ''; + try { + const res = await fetch('/api/admin/ai/models'); + if (!res.ok) throw new Error('Kunne ikke hente modellkatalog'); + catalogModels = await res.json(); + catalogLoaded = true; + } catch (e: any) { + errorMsg = e.message; + } finally { + catalogLoading = false; + } + } + + let groupedByProvider = $derived.by(() => { + const search = catalogSearch.toLowerCase(); + const filtered = search + ? catalogModels.filter( + (m) => + m.name.toLowerCase().includes(search) || + m.id.toLowerCase().includes(search) || + m.provider.toLowerCase().includes(search) + ) + : catalogModels; + + const map = new Map(); + for (const m of filtered) { + const list = map.get(m.provider) ?? []; + list.push(m); + map.set(m.provider, list); + } + + // Sorter modeller synkende etter pris innen hver provider + for (const [, models] of map) { + models.sort((a, b) => b.completion_price_per_m - a.completion_price_per_m); + } + + return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)); + }); + + let catalogPickerFiltered = $derived.by(() => { + const search = catalogPickerSearch.toLowerCase(); + if (!search) return catalogModels.slice(0, 20); + return catalogModels + .filter( + (m) => + m.name.toLowerCase().includes(search) || + m.id.toLowerCase().includes(search) + ) + .slice(0, 20); + }); + + function toggleCatalogProvider(provider: string) { + if (expandedProviders.has(provider)) { + expandedProviders.delete(provider); + } else { + expandedProviders.add(provider); + } + expandedProviders = new Set(expandedProviders); + } + + async function addFromCatalog(model: CatalogModel, aliasId: string) { + errorMsg = ''; + const maxPri = Math.max(0, ...providersForAlias(aliasId).map((p) => p.priority)); + try { + const res = await fetch('/api/admin/ai/providers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + alias_id: aliasId, + priority: maxPri + 1, + litellm_model: `openrouter/${model.id}`, + api_key_env: 'OPENROUTER_API_KEY' + }) + }); + if (!res.ok) throw new Error('Feil ved opprettelse'); + const row = await res.json(); + providers = [...providers, row]; + addingFromCatalog = null; + catalogAddAlias = ''; + } catch { + errorMsg = 'Kunne ikke legge til provider fra katalog'; + } + } + + function selectFromPicker(model: CatalogModel) { + newProvider.litellm_model = `openrouter/${model.id}`; + newProvider.api_key_env = 'OPENROUTER_API_KEY'; + showCatalogPicker = false; + catalogPickerSearch = ''; + } + async function toggleAlias(alias: Alias) { saving = alias.id; errorMsg = ''; @@ -266,11 +417,104 @@ {aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d) + + {#if keysLoaded} +
+ {#each apiKeys as key} + + {key.name.replace('_API_KEY', '')} + {key.configured ? '\u2713' : '\u2717'} + + {/each} +
+ {/if} + {#if errorMsg}
{errorMsg}
{/if} - + +
+
+

Modellkatalog (OpenRouter)

+
+ {#if catalogLoaded} + + {/if} + +
+
+ + {#if catalogLoaded} + {#if groupedByProvider.length === 0} +

Ingen modeller matcher søket.

+ {:else} + {#each groupedByProvider as [providerName, models] (providerName)} +
+ + + {#if expandedProviders.has(providerName)} +
+
+ Modell + Kontekst + Prompt/M + Kompl./M + +
+ + {#each models as model (model.id)} +
+ {model.name} + {formatCtx(model.context_length)} + {formatPrice(model.prompt_price_per_m)} + {formatPrice(model.completion_price_per_m)} + + {#if addingFromCatalog === model.id} + + {:else} + + {/if} + +
+ {/each} +
+ {/if} +
+ {/each} + {/if} + {/if} +
+ +

Modellaliaser

@@ -291,7 +535,7 @@ > {alias.alias} - {alias.description ?? '—'} + {alias.description ?? '\u2014'} {ap.length}
{#if expandedAlias === alias.id} -
+
{#each ap as provider (provider.id)} -
+
#{provider.priority} {provider.litellm_model} {provider.api_key_env} @@ -330,19 +574,51 @@
{/each} -
+
- +
+ + {#if catalogLoaded} + + {/if} +
+ + {#if showCatalogPicker && catalogLoaded} +
+ +
+ {#each catalogPickerFiltered as model (model.id)} + + {/each} +
+
+ {/if}
{/if} {/each} @@ -355,7 +631,7 @@
- +

Jobbruting

@@ -380,7 +656,7 @@ {/each} - {route.description ?? '—'} + {route.description ?? '\u2014'} {#if saving === route.job_type} ... @@ -405,7 +681,7 @@
- +

System-prompts

@@ -420,7 +696,7 @@ {#each prompts as prompt (prompt.action)}
{prompt.action} - {prompt.description ?? '—'} + {prompt.description ?? '\u2014'} {prompt.system_prompt.length} {new Date(prompt.updated_at).toLocaleDateString('nb-NO')} @@ -453,7 +729,7 @@
- +

Tokenforbruk (siste 30 dager)

{#if usage.length === 0} @@ -481,7 +757,7 @@ {/if}
- +

Konfigurasjon

@@ -503,7 +779,7 @@ display: flex; align-items: baseline; gap: 1rem; - margin-bottom: 1.5rem; + margin-bottom: 0.75rem; } h2 { @@ -520,6 +796,34 @@ color: #8b92a5; } + /* API-nøkkel-pills */ + .key-pills { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + } + + .key-pill { + font-size: 0.7rem; + font-weight: 600; + padding: 0.2rem 0.6rem; + border-radius: 9999px; + letter-spacing: 0.03em; + } + + .key-pill--ok { + background: #0d3320; + border: 1px solid #166534; + color: #4ade80; + } + + .key-pill--missing { + background: #3b1219; + border: 1px solid #6b2028; + color: #f87171; + } + section { margin-bottom: 2rem; } @@ -534,6 +838,138 @@ font-size: 0.85rem; } + /* Modellkatalog */ + .catalog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; + } + + .catalog-header h3 { + margin-bottom: 0; + } + + .catalog-actions { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .catalog-search { + background: #0f1117; + border: 1px solid #2d3148; + border-radius: 4px; + color: #e1e4e8; + padding: 0.3rem 0.5rem; + font-size: 0.8rem; + width: 200px; + } + + .catalog-search:focus { + outline: none; + border-color: #3b82f6; + } + + .provider-group { + margin-bottom: 0.5rem; + } + + .provider-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + background: #1a1d2e; + border: 1px solid #2d3148; + border-radius: 6px; + color: #e1e4e8; + cursor: pointer; + font-size: 0.85rem; + text-align: left; + } + + .provider-header:hover { + border-color: #3b82f6; + } + + .provider-chevron { + font-size: 0.6rem; + color: #8b92a5; + width: 1em; + } + + .provider-name { + font-weight: 600; + flex: 1; + } + + .provider-stats { + font-size: 0.75rem; + color: #8b92a5; + } + + .catalog-list { + display: flex; + flex-direction: column; + gap: 1px; + background: #2d3148; + border: 1px solid #2d3148; + border-top: none; + border-radius: 0 0 6px 6px; + overflow: hidden; + } + + .catalog-row { + display: grid; + grid-template-columns: 3fr 70px 80px 80px 100px; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + background: #161822; + font-size: 0.8rem; + } + + .catalog-row--header { + background: #1a1d2e; + font-weight: 600; + color: #8b92a5; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .cat-col-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .cat-col-ctx { + text-align: right; + font-variant-numeric: tabular-nums; + color: #8b92a5; + font-size: 0.75rem; + } + + .cat-col-price { + text-align: right; + font-variant-numeric: tabular-nums; + font-size: 0.75rem; + } + + .cat-col-add { + text-align: right; + } + + .cat-col-add select { + font-size: 0.7rem; + padding: 0.15rem 0.25rem; + } + + /* Alias table */ .table-list { display: flex; flex-direction: column; @@ -616,14 +1052,14 @@ color: #8b92a5; } - /* Provider sub-list */ - .provider-list { + /* Provider sub-list (alias section) */ + .provider-list-alias { background: #0f1117; padding: 0.5rem 0.75rem 0.5rem 2rem; border-bottom: 1px solid #2d3148; } - .provider-row { + .provider-row-alias { display: grid; grid-template-columns: 30px 2fr 1.5fr 60px 60px 40px; align-items: center; @@ -642,6 +1078,87 @@ margin-top: 0.25rem; } + .add-model-input { + display: flex; + gap: 0.25rem; + align-items: center; + } + + .add-model-input input { + flex: 1; + min-width: 0; + } + + .catalog-pick-btn { + white-space: nowrap; + flex-shrink: 0; + } + + /* Catalog picker dropdown */ + .catalog-picker { + background: #161822; + border: 1px solid #3b82f6; + border-radius: 6px; + padding: 0.5rem; + margin-top: 0.5rem; + } + + .catalog-picker-search { + width: 100%; + background: #0f1117; + border: 1px solid #2d3148; + border-radius: 4px; + color: #e1e4e8; + padding: 0.3rem 0.5rem; + font-size: 0.8rem; + margin-bottom: 0.35rem; + } + + .catalog-picker-search:focus { + outline: none; + border-color: #3b82f6; + } + + .catalog-picker-list { + max-height: 200px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1px; + } + + .catalog-picker-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.3rem 0.5rem; + background: #0f1117; + border: none; + color: #e1e4e8; + font-size: 0.75rem; + cursor: pointer; + text-align: left; + border-radius: 3px; + } + + .catalog-picker-item:hover { + background: #1a1d2e; + } + + .picker-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + } + + .picker-meta { + color: #8b92a5; + font-size: 0.7rem; + flex-shrink: 0; + margin-left: 0.5rem; + } + /* Buttons */ .toggle-btn { background: #1a1d2e; @@ -657,6 +1174,11 @@ border-color: #3b82f6; } + .toggle-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .delete-btn { background: none; border: none; @@ -705,8 +1227,7 @@ margin-top: 0.75rem; } - input[type='text'], - input[type='number'] { + input[type='text'] { background: #0f1117; border: 1px solid #2d3148; border-radius: 4px;