diff --git a/migrations/0015_usage_action_column.sql b/migrations/0015_usage_action_column.sql new file mode 100644 index 0000000..bc463a3 --- /dev/null +++ b/migrations/0015_usage_action_column.sql @@ -0,0 +1,8 @@ +-- 0015_usage_action_column.sql +-- Legg til action-kolonne i ai_usage_log for å spore hvilken prompt som ble brukt. + +BEGIN; + +ALTER TABLE ai_usage_log ADD COLUMN IF NOT EXISTS action TEXT; + +COMMIT; diff --git a/web/src/lib/server/db.ts b/web/src/lib/server/db.ts index e3fe71c..52db472 100644 --- a/web/src/lib/server/db.ts +++ b/web/src/lib/server/db.ts @@ -15,10 +15,10 @@ export interface Workspace { settings: Record; } -/** Hent alle workspaces brukeren er medlem av */ -export async function getUserWorkspaces(userId: string): Promise { - return sql` - SELECT w.id, w.name, w.slug, w.domain, w.settings +/** Hent alle workspaces brukeren er medlem av, med rolle */ +export async function getUserWorkspaces(userId: string): Promise<(Workspace & { role: string })[]> { + return sql<(Workspace & { role: string })[]>` + SELECT w.id, w.name, w.slug, w.domain, w.settings, wm.role::text AS role FROM workspaces w JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ${userId} diff --git a/web/src/routes/api/admin/ai/models/+server.ts b/web/src/routes/api/admin/ai/models/+server.ts index 7179a7b..cd58bf9 100644 --- a/web/src/routes/api/admin/ai/models/+server.ts +++ b/web/src/routes/api/admin/ai/models/+server.ts @@ -1,20 +1,13 @@ 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 }; -} +import { sql } from '$lib/server/db'; export interface CatalogModel { id: string; name: string; provider: string; + litellm_prefix: string; + api_key_env: string; context_length: number; prompt_price_per_m: number; completion_price_per_m: number; @@ -22,46 +15,157 @@ export interface CatalogModel { max_completion: number | null; } +interface ProviderFetcher { + keyEnv: string; + label: string; + litellmPrefix: string; + fetch: (apiKey: string) => Promise; +} + 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); +function toPerMillion(pricePerToken: string | number): number { + const n = typeof pricePerToken === 'string' ? parseFloat(pricePerToken) : 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); - } +// --- Provider-spesifikke hentere --- +async function fetchOpenRouter(apiKey: string): Promise { const res = await fetch('https://openrouter.ai/api/v1/models', { headers: { Authorization: `Bearer ${apiKey}` } }); - - if (!res.ok) { - error(502, `OpenRouter returnerte ${res.status}`); - } - + if (!res.ok) return []; const body = await res.json(); - const models: CatalogModel[] = (body.data as OpenRouterModel[]).map((m) => ({ + return (body.data ?? []).map((m: any) => ({ id: m.id, - name: m.name, + name: m.name ?? m.id, provider: m.id.split('/')[0], - context_length: m.context_length, + litellm_prefix: 'openrouter/', + api_key_env: 'OPENROUTER_API_KEY', + context_length: m.context_length ?? 0, 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 })); +} + +async function fetchXai(_apiKey: string): Promise { + // xAI /v1/models krever betalt konto — hardkod kjente modeller + // Kilde: https://docs.x.ai/docs/models + const models = [ + { id: 'grok-4.20-multi-agent-beta-0309', name: 'Grok 4.20 Multi-Agent (beta)', ctx: 131072 }, + { id: 'grok-4.20-beta-0309-reasoning', name: 'Grok 4.20 (reasoning, beta)', ctx: 131072 }, + { id: 'grok-4.20-beta-0309-non-reasoning', name: 'Grok 4.20 (beta)', ctx: 131072 }, + { id: 'grok-4-0709', name: 'Grok 4', ctx: 131072 }, + { id: 'grok-4-fast-reasoning', name: 'Grok 4 Fast (reasoning)', ctx: 131072 }, + { id: 'grok-4-fast-non-reasoning', name: 'Grok 4 Fast', ctx: 131072 }, + { id: 'grok-4-1-fast-reasoning', name: 'Grok 4.1 Fast (reasoning)', ctx: 131072 }, + { id: 'grok-4-1-fast-non-reasoning', name: 'Grok 4.1 Fast', ctx: 131072 }, + { id: 'grok-3', name: 'Grok 3', ctx: 131072 }, + { id: 'grok-3-mini', name: 'Grok 3 Mini', ctx: 131072 }, + { id: 'grok-code-fast-1', name: 'Grok Code Fast', ctx: 131072 }, + ]; + return models.map(m => ({ + id: m.id, + name: m.name, + provider: 'xai', + litellm_prefix: 'xai/', + api_key_env: 'XAI_API_KEY', + context_length: m.ctx, + prompt_price_per_m: -1, + completion_price_per_m: -1, + modality: 'text', + max_completion: null + })); +} + +async function fetchGemini(apiKey: string): Promise { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}` + ); + if (!res.ok) return []; + const body = await res.json(); + return (body.models ?? []) + .filter((m: any) => m.supportedGenerationMethods?.includes('generateContent')) + .map((m: any) => { + // models/gemini-2.5-flash → gemini-2.5-flash + const shortName = (m.name as string).replace('models/', ''); + return { + id: shortName, + name: m.displayName ?? shortName, + provider: 'google', + litellm_prefix: 'gemini/', + api_key_env: 'GEMINI_API_KEY', + context_length: m.inputTokenLimit ?? 0, + prompt_price_per_m: -1, + completion_price_per_m: -1, + modality: 'text', + max_completion: m.outputTokenLimit ?? null + }; + }); +} + +async function fetchOpenAI(apiKey: string): Promise { + const res = await fetch('https://api.openai.com/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` } + }); + if (!res.ok) return []; + const body = await res.json(); + return (body.data ?? []) + .filter((m: any) => m.id.startsWith('gpt-') || m.id.startsWith('o') || m.id.startsWith('chatgpt-')) + .map((m: any) => ({ + id: m.id, + name: m.id, + provider: 'openai', + litellm_prefix: 'openai/', + api_key_env: 'OPENAI_API_KEY', + context_length: 128000, + prompt_price_per_m: 0, + completion_price_per_m: 0, + modality: 'text', + max_completion: null + })); +} + +const PROVIDERS: ProviderFetcher[] = [ + { keyEnv: 'XAI_API_KEY', label: 'xAI', litellmPrefix: 'xai/', fetch: fetchXai }, + { keyEnv: 'GEMINI_API_KEY', label: 'Google', litellmPrefix: 'gemini/', fetch: fetchGemini }, + { keyEnv: 'OPENAI_API_KEY', label: 'OpenAI', litellmPrefix: 'openai/', fetch: fetchOpenAI }, + { keyEnv: 'OPENROUTER_API_KEY', label: 'OpenRouter', litellmPrefix: 'openrouter/', fetch: fetchOpenRouter } +]; + +export const GET: RequestHandler = async ({ locals, url }) => { + if (!locals.workspace || !locals.user) error(401); + + const forceRefresh = url.searchParams.get('refresh') === '1'; + + if (!forceRefresh && cache && Date.now() - cache.fetched_at < CACHE_TTL) { + return json(cache.models); + } + + // Hent aktive nøkler med verdier fra DB + const keys = await sql` + SELECT env_name, key_value FROM ai_api_keys WHERE is_enabled = true AND key_value IS NOT NULL + `; + const keyMap = new Map(keys.map((k: any) => [k.env_name, k.key_value as string])); + + // Hent fra alle aktive leverandører parallelt + const promises = PROVIDERS + .filter(p => keyMap.has(p.keyEnv)) + .map(async (p) => { + try { + return await p.fetch(keyMap.get(p.keyEnv)!); + } catch { + return []; + } + }); + + const results = await Promise.all(promises); + const models = results.flat(); cache = { models, fetched_at: Date.now() }; return json(models); diff --git a/web/src/routes/api/admin/ai/prompts/+server.ts b/web/src/routes/api/admin/ai/prompts/+server.ts new file mode 100644 index 0000000..e742657 --- /dev/null +++ b/web/src/routes/api/admin/ai/prompts/+server.ts @@ -0,0 +1,16 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** GET — list alle AI-prompts */ +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const rows = await sql` + SELECT action, system_prompt, description, updated_at + FROM ai_prompts + ORDER BY action + `; + + return json(rows); +}; diff --git a/web/src/routes/api/admin/ai/prompts/[action]/+server.ts b/web/src/routes/api/admin/ai/prompts/[action]/+server.ts new file mode 100644 index 0000000..15e3d48 --- /dev/null +++ b/web/src/routes/api/admin/ai/prompts/[action]/+server.ts @@ -0,0 +1,44 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** PATCH — oppdater system_prompt, description, label og/eller icon */ +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const body = await request.json(); + + const [row] = await sql` + UPDATE ai_prompts SET + system_prompt = COALESCE(${body.system_prompt ?? null}, system_prompt), + description = COALESCE(${body.description ?? null}, description), + label = COALESCE(${body.label ?? null}, label), + icon = COALESCE(${body.icon ?? null}, icon), + updated_at = now() + WHERE action = ${params.action} + RETURNING action, system_prompt, description, label, icon, sort_order, updated_at + `; + + if (!row) error(404, 'Prompt ikke funnet'); + return json(row); +}; + +/** PUT — opprett eller erstatt prompt for en action */ +export const PUT: RequestHandler = async ({ params, request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const body = await request.json(); + if (!body.system_prompt) error(400, 'system_prompt er påkrevd'); + + const [row] = await sql` + INSERT INTO ai_prompts (action, system_prompt, description) + VALUES (${params.action}, ${body.system_prompt}, ${body.description ?? null}) + ON CONFLICT (action) DO UPDATE SET + system_prompt = EXCLUDED.system_prompt, + description = COALESCE(EXCLUDED.description, ai_prompts.description), + updated_at = now() + RETURNING action, system_prompt, description, updated_at + `; + + return json(row); +}; diff --git a/web/src/routes/api/admin/ai/providers/renumber/+server.ts b/web/src/routes/api/admin/ai/providers/renumber/+server.ts new file mode 100644 index 0000000..2dedb2b --- /dev/null +++ b/web/src/routes/api/admin/ai/providers/renumber/+server.ts @@ -0,0 +1,19 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** POST — renummerer prioriteter for en liste providers */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const items: { id: string; priority: number }[] = await request.json(); + if (!Array.isArray(items) || items.length === 0) error(400); + + for (const item of items) { + await sql` + UPDATE ai_model_providers SET priority = ${item.priority} WHERE id = ${item.id}::uuid + `; + } + + return json({ ok: true }); +}; diff --git a/web/src/routes/server-admin/ai/+page.server.ts b/web/src/routes/server-admin/ai/+page.server.ts index 1f564d9..dafb6b2 100644 --- a/web/src/routes/server-admin/ai/+page.server.ts +++ b/web/src/routes/server-admin/ai/+page.server.ts @@ -22,22 +22,23 @@ export const load: PageServerLoad = async () => { `; const prompts = await sql` - SELECT action, system_prompt, description, updated_at + SELECT action, system_prompt, description, label, icon, sort_order, updated_at FROM ai_prompts - ORDER BY action + ORDER BY sort_order, action `; const usage = await sql` SELECT model_alias, model_actual, + action, count(*)::int AS call_count, sum(prompt_tokens)::int AS prompt_tokens, sum(completion_tokens)::int AS completion_tokens, sum(total_tokens)::int AS total_tokens FROM ai_usage_log WHERE created_at > now() - interval '30 days' - GROUP BY model_alias, model_actual + GROUP BY model_alias, model_actual, action ORDER BY total_tokens DESC `; diff --git a/web/src/routes/server-admin/ai/+page.svelte b/web/src/routes/server-admin/ai/+page.svelte index bcd0153..b434fe1 100644 --- a/web/src/routes/server-admin/ai/+page.svelte +++ b/web/src/routes/server-admin/ai/+page.svelte @@ -31,12 +31,16 @@ action: string; system_prompt: string; description: string | null; + label: string | null; + icon: string | null; + sort_order: number; updated_at: string; } interface UsageRow { model_alias: string; model_actual: string | null; + action: string | null; call_count: number; prompt_tokens: number; completion_tokens: number; @@ -47,6 +51,8 @@ id: string; name: string; provider: string; + litellm_prefix: string; + api_key_env: string; context_length: number; prompt_price_per_m: number; completion_price_per_m: number; @@ -74,6 +80,8 @@ let configMsg = $state(''); let editingPrompt = $state(null); let editPromptText = $state(''); + let editPromptLabel = $state(''); + let editPromptIcon = $state(''); let expandedAlias = $state(null); // Alias-redigering @@ -135,8 +143,9 @@ return String(n); } - function formatPrice(n: number): string { - if (n === 0) return 'Gratis'; + function formatPrice(n: number | null | undefined): string { + if (n == null || n < 0) return '\u2014'; + if (n === 0) return '\u2014'; return `$${n.toFixed(2)}`; } @@ -263,6 +272,7 @@ } } + // Grupper etter api_key_env + provider (f.eks. "google via GEMINI_API_KEY" vs "google via OPENROUTER_API_KEY") let groupedByProvider = $derived.by(() => { const search = catalogSearch.toLowerCase(); const filtered = search @@ -270,23 +280,42 @@ (m) => m.name.toLowerCase().includes(search) || m.id.toLowerCase().includes(search) || - m.provider.toLowerCase().includes(search) + m.provider.toLowerCase().includes(search) || + m.api_key_env.toLowerCase().includes(search) ) : catalogModels; const map = new Map(); for (const m of filtered) { - const list = map.get(m.provider) ?? []; + // Grupper per API-nøkkel, med provider som undergruppe + const groupKey = m.api_key_env === 'OPENROUTER_API_KEY' + ? `${m.provider} (OpenRouter)` + : m.provider; + const list = map.get(groupKey) ?? []; list.push(m); - map.set(m.provider, list); + map.set(groupKey, list); } - // Sorter modeller synkende etter pris innen hver provider + // Sorter: pris synkende først (ukjent/-1 sist), deretter navn synkende for (const [, models] of map) { - models.sort((a, b) => b.completion_price_per_m - a.completion_price_per_m); + models.sort((a, b) => { + const aPrice = a.completion_price_per_m; + const bPrice = b.completion_price_per_m; + const aHasPrice = aPrice > 0; + const bHasPrice = bPrice > 0; + if (aHasPrice !== bHasPrice) return aHasPrice ? -1 : 1; + if (aPrice !== bPrice) return bPrice - aPrice; + return b.name.localeCompare(a.name); + }); } - return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)); + // Direkte API-nøkler først, deretter OpenRouter-grupper + return [...map.entries()].sort(([a], [b]) => { + const aOr = a.includes('OpenRouter'); + const bOr = b.includes('OpenRouter'); + if (aOr !== bOr) return aOr ? 1 : -1; + return a.localeCompare(b); + }); }); let catalogPickerFiltered = $derived.by(() => { @@ -310,43 +339,11 @@ expandedProviders = new Set(expandedProviders); } - // Mapping fra OpenRouter-provider til LiteLLM direkte-prefiks + nøkkel - const directKeyMap: Record = { - google: { prefix: 'gemini/', key: 'GEMINI_API_KEY' }, - anthropic: { prefix: 'anthropic/', key: 'ANTHROPIC_API_KEY' }, - openai: { prefix: 'openai/', key: 'OPENAI_API_KEY' }, - 'x-ai': { prefix: 'xai/', key: 'XAI_API_KEY' }, - }; - - function modelForKey(model: CatalogModel, keyEnv: string): string { - if (keyEnv === 'OPENROUTER_API_KEY') return `openrouter/${model.id}`; - // Direkte: strip provider-prefix fra model.id, legg til LiteLLM-prefiks - const mapping = directKeyMap[model.provider]; - if (mapping) { - const modelName = model.id.replace(`${model.provider}/`, ''); - return `${mapping.prefix}${modelName}`; - } - return `openrouter/${model.id}`; + function litellmModelId(model: CatalogModel): string { + return `${model.litellm_prefix}${model.id}`; } - function availableKeysForModel(model: CatalogModel): ApiKey[] { - const keys: ApiKey[] = []; - // Direkte nøkkel for denne leverandøren - const mapping = directKeyMap[model.provider]; - if (mapping) { - const directKey = apiKeys.find(k => k.name === mapping.key); - if (directKey) keys.push(directKey); - } - // OpenRouter alltid tilgjengelig - const orKey = apiKeys.find(k => k.name === 'OPENROUTER_API_KEY'); - if (orKey) keys.push(orKey); - return keys; - } - - // Catalog add — steg 1: velg alias, steg 2: velg nøkkel - let catalogAddKey = $state(''); - - async function addFromCatalog(model: CatalogModel, aliasId: string, keyEnv: string) { + async function addFromCatalog(model: CatalogModel, aliasId: string) { errorMsg = ''; const maxPri = Math.max(0, ...providersForAlias(aliasId).map((p) => p.priority)); try { @@ -356,8 +353,8 @@ body: JSON.stringify({ alias_id: aliasId, priority: maxPri + 1, - litellm_model: modelForKey(model, keyEnv), - api_key_env: keyEnv + litellm_model: litellmModelId(model), + api_key_env: model.api_key_env }) }); if (!res.ok) throw new Error('Feil ved opprettelse'); @@ -365,14 +362,14 @@ providers = [...providers, row]; addingFromCatalog = null; catalogAddAlias = ''; - catalogAddKey = ''; } catch { errorMsg = 'Kunne ikke legge til provider fra katalog'; } } function selectFromPicker(model: CatalogModel) { - newProvider.litellm_model = modelForKey(model, newProvider.api_key_env); + newProvider.litellm_model = litellmModelId(model); + newProvider.api_key_env = model.api_key_env; showCatalogPicker = false; catalogPickerSearch = ''; } @@ -433,8 +430,12 @@ function estimateCost(row: UsageRow): number | null { if (!catalogLoaded || !row.model_actual) return null; - const model = catalogModels.find((m) => m.id === row.model_actual); - if (!model) return null; + // model_actual kan være "xai/grok-..." eller "google/gemma-..." — match mot id eller litellm_prefix+id + const actual = row.model_actual; + const model = catalogModels.find((m) => + m.id === actual || `${m.litellm_prefix}${m.id}` === actual || `${m.provider}/${m.id}` === actual + ); + if (!model || model.prompt_price_per_m < 0 || model.completion_price_per_m < 0) return null; return ( (row.prompt_tokens / 1_000_000) * model.prompt_price_per_m + (row.completion_tokens / 1_000_000) * model.completion_price_per_m @@ -589,11 +590,33 @@ const res = await fetch(`/api/admin/ai/providers/${provider.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error('Feil'); providers = providers.filter((p) => p.id !== provider.id); + // Renummerer prioriteter for gjenværende providers under samme alias + await renumberPriorities(provider.alias_id); } catch { errorMsg = 'Kunne ikke slette provider'; } } + async function renumberPriorities(aliasId: string) { + const ap = providersForAlias(aliasId); + let changed = false; + for (let i = 0; i < ap.length; i++) { + if (ap[i].priority !== i + 1) { + ap[i].priority = i + 1; + changed = true; + } + } + if (!changed) return; + try { + await fetch('/api/admin/ai/providers/renumber', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ap.map(p => ({ id: p.id, priority: p.priority }))) + }); + providers = [...providers]; + } catch { /* stille */ } + } + async function addAlias() { errorMsg = ''; if (!newAlias.alias) return; @@ -655,11 +678,15 @@ function startEditPrompt(prompt: Prompt) { editingPrompt = prompt.action; editPromptText = prompt.system_prompt; + editPromptLabel = prompt.label ?? ''; + editPromptIcon = prompt.icon ?? ''; } function cancelEditPrompt() { editingPrompt = null; editPromptText = ''; + editPromptLabel = ''; + editPromptIcon = ''; } async function savePrompt(prompt: Prompt) { @@ -669,14 +696,22 @@ const res = await fetch(`/api/admin/ai/prompts/${prompt.action}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ system_prompt: editPromptText }) + body: JSON.stringify({ + system_prompt: editPromptText, + label: editPromptLabel || null, + icon: editPromptIcon || null + }) }); if (!res.ok) throw new Error('Feil ved lagring'); const updated = await res.json(); prompt.system_prompt = updated.system_prompt; + prompt.label = updated.label; + prompt.icon = updated.icon; prompt.updated_at = updated.updated_at; editingPrompt = null; editPromptText = ''; + editPromptLabel = ''; + editPromptIcon = ''; markSaved(prompt.action); } catch { errorMsg = 'Kunne ikke lagre prompt'; @@ -816,7 +851,7 @@
-

Modellkatalog (OpenRouter)

+

Modellkatalog

{#if catalogLoaded} + {/if}
{#if addingFromCatalog === model.id} - {@const modelKeys = availableKeysForModel(model)}
- Legg til {model.name}: + Legg til {model.name} via {model.api_key_env}: -
{/if} @@ -1139,6 +1168,7 @@
Action + Visningsnavn Beskrivelse Tegn Oppdatert @@ -1148,6 +1178,7 @@ {#each prompts as prompt (prompt.action)}
{prompt.action} + {prompt.icon ?? ''} {prompt.label ?? '\u2014'} {prompt.description ?? '\u2014'} {prompt.system_prompt.length} {new Date(prompt.updated_at).toLocaleDateString('nb-NO')} @@ -1164,6 +1195,16 @@ {#if editingPrompt === prompt.action}
+
+ + +