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) {
|
async function handleDelete(id: string) {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
if (!confirm('Slett nøkkelen permanent? Dette kan ikke angres.')) return;
|
if (!confirm('Slett nøkkelen permanent? Dette kan ikke angres.')) return;
|
||||||
|
|
@ -306,27 +327,41 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-3 flex shrink-0 gap-2">
|
<div class="ml-3 flex shrink-0 flex-col gap-1">
|
||||||
<button
|
<div class="flex gap-2">
|
||||||
onclick={() => handleToggleActive(key.id)}
|
<button
|
||||||
disabled={actionLoading === `toggle-${key.id}`}
|
onclick={() => handleTestExisting(key.id)}
|
||||||
class="rounded px-2 py-1 text-xs {key.is_active
|
disabled={actionLoading === `test-${key.id}`}
|
||||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
|
class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200 disabled:opacity-50"
|
||||||
: 'bg-green-100 text-green-700 hover:bg-green-200'} disabled:opacity-50"
|
>
|
||||||
>
|
{actionLoading === `test-${key.id}` ? 'Tester...' : 'Test'}
|
||||||
{#if actionLoading === `toggle-${key.id}`}
|
</button>
|
||||||
...
|
<button
|
||||||
{:else}
|
onclick={() => handleToggleActive(key.id)}
|
||||||
{key.is_active ? 'Deaktiver' : 'Aktiver'}
|
disabled={actionLoading === `toggle-${key.id}`}
|
||||||
{/if}
|
class="rounded px-2 py-1 text-xs {key.is_active
|
||||||
</button>
|
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
|
||||||
<button
|
: 'bg-green-100 text-green-700 hover:bg-green-200'} disabled:opacity-50"
|
||||||
onclick={() => handleDelete(key.id)}
|
>
|
||||||
disabled={actionLoading === `del-${key.id}`}
|
{#if actionLoading === `toggle-${key.id}`}
|
||||||
class="rounded bg-red-100 px-2 py-1 text-xs text-red-700 hover:bg-red-200 disabled:opacity-50"
|
...
|
||||||
>
|
{:else}
|
||||||
{actionLoading === `del-${key.id}` ? '...' : 'Slett'}
|
{key.is_active ? 'Deaktiver' : 'Aktiver'}
|
||||||
</button>
|
{/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>
|
</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
|
// 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", get(api_keys_admin::list_keys))
|
||||||
.route("/admin/api-keys/create", post(api_keys_admin::create_key))
|
.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", 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/deactivate", post(api_keys_admin::deactivate_key))
|
||||||
.route("/admin/api-keys/delete", post(api_keys_admin::delete_key))
|
.route("/admin/api-keys/delete", post(api_keys_admin::delete_key))
|
||||||
// Webhook-admin (oppgave 29.5)
|
// 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