From 052444c2a079b00fb6a61f52ec328d12cd98f1f4 Mon Sep 17 00:00:00 2001 From: vegard Date: Fri, 20 Mar 2026 07:08:52 +0000 Subject: [PATCH] =?UTF-8?q?API-n=C3=B8kler:=20test=20eksisterende=20n?= =?UTF-8?q?=C3=B8kler=20+=20model=5Fconfig=20tabell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ny rute POST /admin/api-keys/test-existing: dekrypterer og tester lagret nøkkel by ID (bruker SYNOPS_MASTER_KEY) - Test-knapp per nøkkel i admin-UI (ved siden av deaktiver/slett) - Testresultat vises inline (grønn/rød) - model_config tabell: erstatter LiteLLM YAML, mapper alias → provider + modell - model_pricing tabell: kostnadsestimat for admin-UI Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/routes/admin/keys/+page.svelte | 77 +++++++++++++++------ maskinrommet/src/api_keys_admin.rs | 34 +++++++++ maskinrommet/src/main.rs | 1 + migrations/034_model_config.sql | 42 +++++++++++ 4 files changed, 133 insertions(+), 21 deletions(-) create mode 100644 migrations/034_model_config.sql diff --git a/frontend/src/routes/admin/keys/+page.svelte b/frontend/src/routes/admin/keys/+page.svelte index 8d4c00e..c5256fd 100644 --- a/frontend/src/routes/admin/keys/+page.svelte +++ b/frontend/src/routes/admin/keys/+page.svelte @@ -124,6 +124,27 @@ } } + let testExistingResult = $state>({}); + + async function handleTestExisting(id: string) { + if (!accessToken) return; + actionLoading = `test-${id}`; + testExistingResult = { ...testExistingResult, [id]: null }; + try { + const res = await fetch('/api/admin/api-keys/test-existing', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({ id }), + }); + const data = await res.json(); + testExistingResult = { ...testExistingResult, [id]: data }; + } catch (e) { + testExistingResult = { ...testExistingResult, [id]: { success: false, message: String(e) } }; + } finally { + actionLoading = null; + } + } + async function handleDelete(id: string) { if (!accessToken) return; if (!confirm('Slett nøkkelen permanent? Dette kan ikke angres.')) return; @@ -306,27 +327,41 @@ -
- - +
+
+ + + +
+ {#if testExistingResult[key.id]} + + {testExistingResult[key.id]?.message} + + {/if}
diff --git a/maskinrommet/src/api_keys_admin.rs b/maskinrommet/src/api_keys_admin.rs index 0941168..91b0683 100644 --- a/maskinrommet/src/api_keys_admin.rs +++ b/maskinrommet/src/api_keys_admin.rs @@ -251,6 +251,40 @@ pub async fn test_key( } } +// ============================================================================= +// POST /admin/api-keys/test-existing — test en lagret nøkkel (dekrypterer fra PG) +// ============================================================================= + +#[derive(Deserialize)] +pub struct TestExistingKeyRequest { + pub id: Uuid, +} + +pub async fn test_existing_key( + admin: AdminUser, + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Hent kryptert nøkkel fra PG + let row: Option<(String, Vec)> = sqlx::query_as( + "SELECT provider, key_encrypted FROM api_keys WHERE id = $1" + ).bind(req.id) + .fetch_optional(&state.db).await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: format!("DB-feil: {e}") })))?; + + let (provider, encrypted) = row + .ok_or_else(|| (StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Nøkkel ikke funnet".into() })))?; + + // Dekrypter + let master = crypto::master_key() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?; + let api_key = crypto::decrypt(&encrypted, &master) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?; + + // Gjenbruk eksisterende test-logikk + test_key(admin, Json(TestKeyRequest { provider, api_key })).await +} + // ============================================================================= // POST /admin/api-keys/deactivate — deaktiver nøkkel // ============================================================================= diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index eded4da..7b88db9 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -333,6 +333,7 @@ async fn main() { .route("/admin/api-keys", get(api_keys_admin::list_keys)) .route("/admin/api-keys/create", post(api_keys_admin::create_key)) .route("/admin/api-keys/test", post(api_keys_admin::test_key)) + .route("/admin/api-keys/test-existing", post(api_keys_admin::test_existing_key)) .route("/admin/api-keys/deactivate", post(api_keys_admin::deactivate_key)) .route("/admin/api-keys/delete", post(api_keys_admin::delete_key)) // Webhook-admin (oppgave 29.5) diff --git a/migrations/034_model_config.sql b/migrations/034_model_config.sql new file mode 100644 index 0000000..68504c5 --- /dev/null +++ b/migrations/034_model_config.sql @@ -0,0 +1,42 @@ +-- Model config: erstatter LiteLLM YAML. +-- Mapper alias (synops/low, synops/high) → provider + modell. +-- Fallback-kjede via priority. + +CREATE TABLE IF NOT EXISTS model_config ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + alias TEXT NOT NULL, -- "synops/low", "synops/high", etc. + provider TEXT NOT NULL, -- "openrouter", "anthropic", "gemini", "xai", "openai", "ollama" + model TEXT NOT NULL, -- "google/gemini-2.5-flash", "anthropic/claude-sonnet-4", etc. + priority SMALLINT NOT NULL DEFAULT 1, -- lavere = foretrukket. Fallback ved feil. + is_active BOOLEAN NOT NULL DEFAULT true, + max_tokens INTEGER DEFAULT 4096, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_model_config_alias ON model_config(alias, priority) WHERE is_active = true; + +-- Seed: standard modell-konfigurasjon +INSERT INTO model_config (alias, provider, model, priority, description) VALUES + ('synops/low', 'openrouter', 'google/gemini-2.5-flash', 1, 'Billig, rask — rutine, klassifisering'), + ('synops/medium', 'openrouter', 'google/gemini-2.5-flash', 1, 'Mellomting — implementering, analyse'), + ('synops/high', 'openrouter', 'anthropic/claude-sonnet-4', 1, 'Resonering, kreativitet, presisjon'), + ('synops/high', 'openrouter', 'google/gemini-2.5-flash', 2, 'Fallback for synops/high'), + ('synops/extreme', 'openrouter', 'anthropic/claude-opus-4', 1, 'Tung arkitektur og beslutninger') +ON CONFLICT DO NOTHING; + +-- Modellpriser for kostnadsestimat i admin-UI +CREATE TABLE IF NOT EXISTS model_pricing ( + provider TEXT NOT NULL, + model TEXT NOT NULL, + input_price_per_m NUMERIC, -- pris per million input-tokens + output_price_per_m NUMERIC, -- pris per million output-tokens + updated_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (provider, model) +); + +INSERT INTO model_pricing (provider, model, input_price_per_m, output_price_per_m) VALUES + ('openrouter', 'google/gemini-2.5-flash', 0.15, 0.60), + ('openrouter', 'anthropic/claude-sonnet-4', 3.00, 15.00), + ('openrouter', 'anthropic/claude-opus-4', 15.00, 75.00) +ON CONFLICT DO NOTHING;