API-nøkler: test eksisterende nøkler + model_config tabell
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
fc509ea841
commit
052444c2a0
4 changed files with 133 additions and 21 deletions
|
|
@ -124,6 +124,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
let testExistingResult = $state<Record<string, { success: boolean; message: string } | null>>({});
|
||||
|
||||
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 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-3 flex shrink-0 gap-2">
|
||||
<button
|
||||
onclick={() => handleToggleActive(key.id)}
|
||||
disabled={actionLoading === `toggle-${key.id}`}
|
||||
class="rounded px-2 py-1 text-xs {key.is_active
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
|
||||
: 'bg-green-100 text-green-700 hover:bg-green-200'} disabled:opacity-50"
|
||||
>
|
||||
{#if actionLoading === `toggle-${key.id}`}
|
||||
...
|
||||
{:else}
|
||||
{key.is_active ? 'Deaktiver' : 'Aktiver'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDelete(key.id)}
|
||||
disabled={actionLoading === `del-${key.id}`}
|
||||
class="rounded bg-red-100 px-2 py-1 text-xs text-red-700 hover:bg-red-200 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === `del-${key.id}` ? '...' : 'Slett'}
|
||||
</button>
|
||||
<div class="ml-3 flex shrink-0 flex-col gap-1">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => handleTestExisting(key.id)}
|
||||
disabled={actionLoading === `test-${key.id}`}
|
||||
class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === `test-${key.id}` ? 'Tester...' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleToggleActive(key.id)}
|
||||
disabled={actionLoading === `toggle-${key.id}`}
|
||||
class="rounded px-2 py-1 text-xs {key.is_active
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
|
||||
: 'bg-green-100 text-green-700 hover:bg-green-200'} disabled:opacity-50"
|
||||
>
|
||||
{#if actionLoading === `toggle-${key.id}`}
|
||||
...
|
||||
{:else}
|
||||
{key.is_active ? 'Deaktiver' : 'Aktiver'}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDelete(key.id)}
|
||||
disabled={actionLoading === `del-${key.id}`}
|
||||
class="rounded bg-red-100 px-2 py-1 text-xs text-red-700 hover:bg-red-200 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === `del-${key.id}` ? '...' : 'Slett'}
|
||||
</button>
|
||||
</div>
|
||||
{#if testExistingResult[key.id]}
|
||||
<span class="text-xs {testExistingResult[key.id]?.success ? 'text-green-600' : 'text-red-600'}">
|
||||
{testExistingResult[key.id]?.message}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<crate::AppState>,
|
||||
Json(req): Json<TestExistingKeyRequest>,
|
||||
) -> Result<Json<TestKeyResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
// Hent kryptert nøkkel fra PG
|
||||
let row: Option<(String, Vec<u8>)> = 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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
42
migrations/034_model_config.sql
Normal file
42
migrations/034_model_config.sql
Normal file
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue