AI-admin: alias-rename, dollarkostnad, fikset kolonnebredder

- PATCH /aliases/:id støtter nå rename (alias-felt)
- Alias-raden har «Rediger»-knapp → inline-redigering av navn + beskrivelse
- Tokenforbruk viser model_actual og estimert dollarkostnad per rad
- Dollarkostnad beregnes fra OpenRouter-katalogpriser (krever lastet katalog)
- Tokenforbruk-tabellen bruker auto-kolonnebredde (fikser overflow)
- «Kompl.» i stedet for «Completion» i header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 05:36:49 +01:00
parent 177e4b6b66
commit 21683bd660
3 changed files with 1512 additions and 1377 deletions

View file

@ -7,12 +7,10 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401); if (!locals.workspace || !locals.user) error(401);
const body = await request.json(); const body = await request.json();
const updates: Record<string, unknown> = {};
if ('description' in body) updates.description = body.description;
if ('is_active' in body) updates.is_active = body.is_active;
const [row] = await sql` const [row] = await sql`
UPDATE ai_model_aliases SET UPDATE ai_model_aliases SET
alias = COALESCE(${body.alias ?? null}, alias),
description = COALESCE(${body.description ?? null}, description), description = COALESCE(${body.description ?? null}, description),
is_active = COALESCE(${body.is_active ?? null}, is_active), is_active = COALESCE(${body.is_active ?? null}, is_active),
updated_at = now() updated_at = now()

View file

@ -30,13 +30,14 @@ export const load: PageServerLoad = async () => {
const usage = await sql` const usage = await sql`
SELECT SELECT
model_alias, model_alias,
model_actual,
count(*)::int AS call_count, count(*)::int AS call_count,
sum(prompt_tokens)::int AS prompt_tokens, sum(prompt_tokens)::int AS prompt_tokens,
sum(completion_tokens)::int AS completion_tokens, sum(completion_tokens)::int AS completion_tokens,
sum(total_tokens)::int AS total_tokens sum(total_tokens)::int AS total_tokens
FROM ai_usage_log FROM ai_usage_log
WHERE created_at > now() - interval '30 days' WHERE created_at > now() - interval '30 days'
GROUP BY model_alias GROUP BY model_alias, model_actual
ORDER BY total_tokens DESC ORDER BY total_tokens DESC
`; `;

View file

@ -35,6 +35,7 @@
interface UsageRow { interface UsageRow {
model_alias: string; model_alias: string;
model_actual: string | null;
call_count: number; call_count: number;
prompt_tokens: number; prompt_tokens: number;
completion_tokens: number; completion_tokens: number;
@ -71,6 +72,11 @@
let editPromptText = $state(''); let editPromptText = $state('');
let expandedAlias = $state<string | null>(null); let expandedAlias = $state<string | null>(null);
// Alias-redigering
let editingAlias = $state<string | null>(null);
let editAliasName = $state('');
let editAliasDesc = $state('');
// API-nøkler // API-nøkler
let apiKeys = $state<ApiKey[]>([]); let apiKeys = $state<ApiKey[]>([]);
let keysLoaded = $state(false); let keysLoaded = $state(false);
@ -250,6 +256,64 @@
} }
} }
function startEditAlias(alias: Alias) {
editingAlias = alias.id;
editAliasName = alias.alias;
editAliasDesc = alias.description ?? '';
}
function cancelEditAlias() {
editingAlias = null;
editAliasName = '';
editAliasDesc = '';
}
async function saveAlias(alias: Alias) {
saving = alias.id;
errorMsg = '';
try {
const res = await fetch(`/api/admin/ai/aliases/${alias.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias: editAliasName, description: editAliasDesc || null })
});
if (!res.ok) throw new Error('Feil ved lagring');
const updated = await res.json();
alias.alias = updated.alias;
alias.description = updated.description;
editingAlias = null;
markSaved(alias.id);
} catch {
errorMsg = 'Kunne ikke oppdatere alias';
} finally {
saving = null;
}
}
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;
return (
(row.prompt_tokens / 1_000_000) * model.prompt_price_per_m +
(row.completion_tokens / 1_000_000) * model.completion_price_per_m
);
}
let totalEstimatedCost = $derived.by(() => {
if (!catalogLoaded) return null;
let total = 0;
let hasAny = false;
for (const row of usage) {
const cost = estimateCost(row);
if (cost !== null) {
total += cost;
hasAny = true;
}
}
return hasAny ? total : null;
});
async function toggleProvider(provider: Provider) { async function toggleProvider(provider: Provider) {
saving = provider.id; saving = provider.id;
errorMsg = ''; errorMsg = '';
@ -536,6 +600,22 @@
{#each aliases as alias (alias.id)} {#each aliases as alias (alias.id)}
{@const ap = providersForAlias(alias.id)} {@const ap = providersForAlias(alias.id)}
{#if editingAlias === alias.id}
<div class="table-row alias-edit-row">
<input type="text" class="alias-edit-input" bind:value={editAliasName} placeholder="Alias-navn" />
<input type="text" class="alias-edit-input" bind:value={editAliasDesc} placeholder="Beskrivelse" />
<span></span>
<span class="alias-edit-actions">
<button class="add-btn" onclick={() => saveAlias(alias)}>Lagre</button>
<button class="toggle-btn" onclick={cancelEditAlias}>Avbryt</button>
</span>
<span class="col-status">
{#if saving === alias.id}
<span class="status-saving">...</span>
{/if}
</span>
</div>
{:else}
<div class="table-row" class:table-row--inactive={!alias.is_active}> <div class="table-row" class:table-row--inactive={!alias.is_active}>
<span <span
class="col-alias clickable" class="col-alias clickable"
@ -545,7 +625,8 @@
</span> </span>
<span class="col-desc">{alias.description ?? '\u2014'}</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 alias-action-group">
<button class="toggle-btn" onclick={() => startEditAlias(alias)}>Rediger</button>
<button class="toggle-btn" onclick={() => toggleAlias(alias)}> <button class="toggle-btn" onclick={() => toggleAlias(alias)}>
{alias.is_active ? 'På' : 'Av'} {alias.is_active ? 'På' : 'Av'}
</button> </button>
@ -558,6 +639,7 @@
{/if} {/if}
</span> </span>
</div> </div>
{/if}
{#if expandedAlias === alias.id} {#if expandedAlias === alias.id}
<div class="provider-list-alias"> <div class="provider-list-alias">
@ -739,29 +821,42 @@
<!-- Seksjon 5: Tokenforbruk --> <!-- Seksjon 5: Tokenforbruk -->
<section> <section>
<div class="catalog-header">
<h3>Tokenforbruk (siste 30 dager)</h3> <h3>Tokenforbruk (siste 30 dager)</h3>
{#if totalEstimatedCost !== null}
<span class="usage-total-cost">Estimert totalkostnad: ${totalEstimatedCost.toFixed(2)}</span>
{/if}
</div>
{#if usage.length === 0} {#if usage.length === 0}
<p class="hint">Ingen AI-kall registrert ennå.</p> <p class="hint">Ingen AI-kall registrert ennå.</p>
{:else} {:else}
<div class="table-list"> <div class="table-list">
<div class="table-row table-row--header"> <div class="table-row table-row--header usage-row">
<span class="col-alias">Modellalias</span> <span>Alias</span>
<span>Modell</span>
<span class="col-num">Kall</span> <span class="col-num">Kall</span>
<span class="col-num">Prompt</span> <span class="col-num">Prompt</span>
<span class="col-num">Completion</span> <span class="col-num">Kompl.</span>
<span class="col-num">Totalt</span> <span class="col-num">Totalt</span>
<span class="col-num">Est. $</span>
</div> </div>
{#each usage as row} {#each usage as row}
<div class="table-row"> {@const cost = estimateCost(row)}
<div class="table-row usage-row">
<span class="col-alias">{row.model_alias}</span> <span class="col-alias">{row.model_alias}</span>
<span class="col-model-actual">{row.model_actual ?? '\u2014'}</span>
<span class="col-num">{row.call_count}</span> <span class="col-num">{row.call_count}</span>
<span class="col-num">{row.prompt_tokens.toLocaleString('nb-NO')}</span> <span class="col-num">{row.prompt_tokens.toLocaleString('nb-NO')}</span>
<span class="col-num">{row.completion_tokens.toLocaleString('nb-NO')}</span> <span class="col-num">{row.completion_tokens.toLocaleString('nb-NO')}</span>
<span class="col-num">{row.total_tokens.toLocaleString('nb-NO')}</span> <span class="col-num">{row.total_tokens.toLocaleString('nb-NO')}</span>
<span class="col-num">{cost !== null ? `$${cost.toFixed(2)}` : '\u2014'}</span>
</div> </div>
{/each} {/each}
</div> </div>
{#if !catalogLoaded}
<p class="hint">Last inn modellkatalogen for å se estimerte kostnader.</p>
{/if}
{/if} {/if}
</section> </section>
@ -1040,6 +1135,27 @@
text-align: center; text-align: center;
} }
/* Alias edit */
.alias-edit-row {
grid-template-columns: 2fr 2fr 1fr auto 40px;
}
.alias-edit-input {
width: 100%;
}
.alias-edit-actions {
display: flex;
gap: 0.35rem;
white-space: nowrap;
}
.alias-action-group {
display: flex;
gap: 0.25rem;
justify-content: center;
}
.col-num { .col-num {
text-align: right; text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@ -1305,6 +1421,26 @@
font-style: italic; font-style: italic;
} }
/* Usage/tokenforbruk */
.usage-row {
grid-template-columns: auto auto auto auto auto auto auto;
}
.col-model-actual {
font-family: monospace;
font-size: 0.7rem;
color: #8b92a5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-total-cost {
font-size: 0.85rem;
color: #4ade80;
font-weight: 600;
}
/* Prompt-seksjon */ /* Prompt-seksjon */
.prompt-row { .prompt-row {
grid-template-columns: 1.5fr 2.5fr 60px 90px 70px; grid-template-columns: 1.5fr 2.5fr 60px 90px 70px;