AI-admin: modellkatalog fra OpenRouter med leverandør-akkordion

- 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 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 05:23:47 +01:00
parent aafb121bf2
commit b1a7e55fff
3 changed files with 627 additions and 22 deletions

View file

@ -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 });
};

View file

@ -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);
};

View file

@ -41,6 +41,22 @@
total_tokens: number; 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<Alias[]>(data.aliases as Alias[]); let aliases = $state<Alias[]>(data.aliases as Alias[]);
let providers = $state<Provider[]>(data.providers as Provider[]); let providers = $state<Provider[]>(data.providers as Provider[]);
let routing = $state<Route[]>(data.routing as Route[]); let routing = $state<Route[]>(data.routing as Route[]);
@ -55,6 +71,23 @@
let editPromptText = $state(''); let editPromptText = $state('');
let expandedAlias = $state<string | null>(null); let expandedAlias = $state<string | null>(null);
// API-nøkler
let apiKeys = $state<ApiKey[]>([]);
let keysLoaded = $state(false);
// Modellkatalog
let catalogModels = $state<CatalogModel[]>([]);
let catalogLoading = $state(false);
let catalogLoaded = $state(false);
let catalogSearch = $state('');
let expandedProviders = $state<Set<string>>(new Set());
let addingFromCatalog = $state<string | null>(null);
let catalogAddAlias = $state('');
// Katalog-picker i add-provider-form
let showCatalogPicker = $state(false);
let catalogPickerSearch = $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: '',
@ -79,6 +112,124 @@
}, 2000); }, 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<string, CatalogModel[]>();
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) { async function toggleAlias(alias: Alias) {
saving = alias.id; saving = alias.id;
errorMsg = ''; errorMsg = '';
@ -266,11 +417,104 @@
<span class="header-stats">{aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d)</span> <span class="header-stats">{aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d)</span>
</div> </div>
<!-- API-nøkkel-pills -->
{#if keysLoaded}
<div class="key-pills">
{#each apiKeys as key}
<span class="key-pill" class:key-pill--ok={key.configured} class:key-pill--missing={!key.configured}>
{key.name.replace('_API_KEY', '')}
{key.configured ? '\u2713' : '\u2717'}
</span>
{/each}
</div>
{/if}
{#if errorMsg} {#if errorMsg}
<div class="error-msg">{errorMsg}</div> <div class="error-msg">{errorMsg}</div>
{/if} {/if}
<!-- Seksjon 1: Modellaliaser --> <!-- Seksjon 1: Modellkatalog -->
<section>
<div class="catalog-header">
<h3>Modellkatalog (OpenRouter)</h3>
<div class="catalog-actions">
{#if catalogLoaded}
<input
type="text"
class="catalog-search"
placeholder="Søk modeller..."
bind:value={catalogSearch}
/>
{/if}
<button
class="toggle-btn"
onclick={loadCatalog}
disabled={catalogLoading}
>
{catalogLoading ? 'Laster...' : catalogLoaded ? 'Oppdater' : 'Last inn katalog'}
</button>
</div>
</div>
{#if catalogLoaded}
{#if groupedByProvider.length === 0}
<p class="hint">Ingen modeller matcher søket.</p>
{:else}
{#each groupedByProvider as [providerName, models] (providerName)}
<div class="provider-group">
<button class="provider-header" onclick={() => toggleCatalogProvider(providerName)}>
<span class="provider-chevron">{expandedProviders.has(providerName) ? '\u25BC' : '\u25B6'}</span>
<span class="provider-name">{providerName}</span>
<span class="provider-stats">{models.length} modeller</span>
</button>
{#if expandedProviders.has(providerName)}
<div class="catalog-list">
<div class="catalog-row catalog-row--header">
<span class="cat-col-name">Modell</span>
<span class="cat-col-ctx">Kontekst</span>
<span class="cat-col-price">Prompt/M</span>
<span class="cat-col-price">Kompl./M</span>
<span class="cat-col-add"></span>
</div>
{#each models as model (model.id)}
<div class="catalog-row">
<span class="cat-col-name" title={model.id}>{model.name}</span>
<span class="cat-col-ctx">{formatCtx(model.context_length)}</span>
<span class="cat-col-price">{formatPrice(model.prompt_price_per_m)}</span>
<span class="cat-col-price">{formatPrice(model.completion_price_per_m)}</span>
<span class="cat-col-add">
{#if addingFromCatalog === model.id}
<select
bind:value={catalogAddAlias}
onchange={() => {
if (catalogAddAlias) addFromCatalog(model, catalogAddAlias);
}}
>
<option value="">Velg alias...</option>
{#each aliases as a}
<option value={a.id}>{a.alias}</option>
{/each}
</select>
{:else}
<button
class="toggle-btn"
onclick={() => { addingFromCatalog = model.id; catalogAddAlias = ''; }}
>Legg til &rarr;</button>
{/if}
</span>
</div>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
{/if}
</section>
<!-- Seksjon 2: Modellaliaser -->
<section> <section>
<h3>Modellaliaser</h3> <h3>Modellaliaser</h3>
<div class="table-list"> <div class="table-list">
@ -291,7 +535,7 @@
> >
{alias.alias} {alias.alias}
</span> </span>
<span class="col-desc">{alias.description ?? ''}</span> <span class="col-desc">{alias.description ?? '\u2014'}</span>
<span class="col-providers">{ap.length}</span> <span class="col-providers">{ap.length}</span>
<span class="col-active"> <span class="col-active">
<button class="toggle-btn" onclick={() => toggleAlias(alias)}> <button class="toggle-btn" onclick={() => toggleAlias(alias)}>
@ -308,9 +552,9 @@
</div> </div>
{#if expandedAlias === alias.id} {#if expandedAlias === alias.id}
<div class="provider-list"> <div class="provider-list-alias">
{#each ap as provider (provider.id)} {#each ap as provider (provider.id)}
<div class="provider-row" class:provider-row--inactive={!provider.is_active}> <div class="provider-row-alias" class:provider-row--inactive={!provider.is_active}>
<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>
@ -330,19 +574,51 @@
</div> </div>
{/each} {/each}
<div class="provider-row provider-row--add"> <div class="provider-row-alias provider-row--add">
<span class="col-pri"></span> <span class="col-pri"></span>
<div class="add-model-input">
<input <input
type="text" type="text"
placeholder="gemini/modell-navn" placeholder="gemini/modell-navn"
bind:value={newProvider.litellm_model} bind:value={newProvider.litellm_model}
/> />
{#if catalogLoaded}
<button
class="toggle-btn catalog-pick-btn"
onclick={() => { showCatalogPicker = !showCatalogPicker; catalogPickerSearch = ''; }}
>Katalog</button>
{/if}
</div>
<select bind:value={newProvider.api_key_env}> <select bind:value={newProvider.api_key_env}>
<option value="GEMINI_API_KEY">GEMINI_API_KEY</option> <option value="GEMINI_API_KEY">GEMINI_API_KEY</option>
<option value="OPENROUTER_API_KEY">OPENROUTER_API_KEY</option> <option value="OPENROUTER_API_KEY">OPENROUTER_API_KEY</option>
<option value="ANTHROPIC_API_KEY">ANTHROPIC_API_KEY</option>
<option value="XAI_API_KEY">XAI_API_KEY</option>
</select> </select>
<button class="add-btn" onclick={() => addProvider(alias.id)}>Legg til</button> <button class="add-btn" onclick={() => addProvider(alias.id)}>Legg til</button>
</div> </div>
{#if showCatalogPicker && catalogLoaded}
<div class="catalog-picker">
<input
type="text"
class="catalog-picker-search"
placeholder="Søk i katalog..."
bind:value={catalogPickerSearch}
/>
<div class="catalog-picker-list">
{#each catalogPickerFiltered as model (model.id)}
<button
class="catalog-picker-item"
onclick={() => selectFromPicker(model)}
>
<span class="picker-name">{model.name}</span>
<span class="picker-meta">{formatCtx(model.context_length)} / {formatPrice(model.completion_price_per_m)}</span>
</button>
{/each}
</div>
</div>
{/if}
</div> </div>
{/if} {/if}
{/each} {/each}
@ -355,7 +631,7 @@
</div> </div>
</section> </section>
<!-- Seksjon 2: Jobbruting --> <!-- Seksjon 3: Jobbruting -->
<section> <section>
<h3>Jobbruting</h3> <h3>Jobbruting</h3>
<div class="table-list"> <div class="table-list">
@ -380,7 +656,7 @@
{/each} {/each}
</select> </select>
</span> </span>
<span class="col-desc">{route.description ?? ''}</span> <span class="col-desc">{route.description ?? '\u2014'}</span>
<span class="col-status"> <span class="col-status">
{#if saving === route.job_type} {#if saving === route.job_type}
<span class="status-saving">...</span> <span class="status-saving">...</span>
@ -405,7 +681,7 @@
</div> </div>
</section> </section>
<!-- Seksjon 3: System-prompts --> <!-- Seksjon 4: System-prompts -->
<section> <section>
<h3>System-prompts</h3> <h3>System-prompts</h3>
<div class="table-list"> <div class="table-list">
@ -420,7 +696,7 @@
{#each prompts as prompt (prompt.action)} {#each prompts as prompt (prompt.action)}
<div class="table-row prompt-row"> <div class="table-row prompt-row">
<span class="col-action">{prompt.action}</span> <span class="col-action">{prompt.action}</span>
<span class="col-desc">{prompt.description ?? ''}</span> <span class="col-desc">{prompt.description ?? '\u2014'}</span>
<span class="col-chars">{prompt.system_prompt.length}</span> <span class="col-chars">{prompt.system_prompt.length}</span>
<span class="col-updated">{new Date(prompt.updated_at).toLocaleDateString('nb-NO')}</span> <span class="col-updated">{new Date(prompt.updated_at).toLocaleDateString('nb-NO')}</span>
<span class="col-edit"> <span class="col-edit">
@ -453,7 +729,7 @@
</div> </div>
</section> </section>
<!-- Seksjon 4: Tokenforbruk --> <!-- Seksjon 5: Tokenforbruk -->
<section> <section>
<h3>Tokenforbruk (siste 30 dager)</h3> <h3>Tokenforbruk (siste 30 dager)</h3>
{#if usage.length === 0} {#if usage.length === 0}
@ -481,7 +757,7 @@
{/if} {/if}
</section> </section>
<!-- Seksjon 5: Konfigurasjon --> <!-- Seksjon 6: Konfigurasjon -->
<section> <section>
<h3>Konfigurasjon</h3> <h3>Konfigurasjon</h3>
<div class="config-box"> <div class="config-box">
@ -503,7 +779,7 @@
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 1rem; gap: 1rem;
margin-bottom: 1.5rem; margin-bottom: 0.75rem;
} }
h2 { h2 {
@ -520,6 +796,34 @@
color: #8b92a5; 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 { section {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@ -534,6 +838,138 @@
font-size: 0.85rem; 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 { .table-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -616,14 +1052,14 @@
color: #8b92a5; color: #8b92a5;
} }
/* Provider sub-list */ /* Provider sub-list (alias section) */
.provider-list { .provider-list-alias {
background: #0f1117; background: #0f1117;
padding: 0.5rem 0.75rem 0.5rem 2rem; padding: 0.5rem 0.75rem 0.5rem 2rem;
border-bottom: 1px solid #2d3148; border-bottom: 1px solid #2d3148;
} }
.provider-row { .provider-row-alias {
display: grid; display: grid;
grid-template-columns: 30px 2fr 1.5fr 60px 60px 40px; grid-template-columns: 30px 2fr 1.5fr 60px 60px 40px;
align-items: center; align-items: center;
@ -642,6 +1078,87 @@
margin-top: 0.25rem; 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 */ /* Buttons */
.toggle-btn { .toggle-btn {
background: #1a1d2e; background: #1a1d2e;
@ -657,6 +1174,11 @@
border-color: #3b82f6; border-color: #3b82f6;
} }
.toggle-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-btn { .delete-btn {
background: none; background: none;
border: none; border: none;
@ -705,8 +1227,7 @@
margin-top: 0.75rem; margin-top: 0.75rem;
} }
input[type='text'], input[type='text'] {
input[type='number'] {
background: #0f1117; background: #0f1117;
border: 1px solid #2d3148; border: 1px solid #2d3148;
border-radius: 4px; border-radius: 4px;