Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/admin/src/lib/api/requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ function rawRecord(overrides: Record<string, unknown> = {}): Record<string, unkn
},
{
alias: 'premium',
provider_name: 'anthropic',
provider_model: 'claude-x',
skipped: false,
skip_reason: null,
status: 'ok',
Expand All @@ -53,6 +55,7 @@ function rawRecord(overrides: Record<string, unknown> = {}): Record<string, unkn
status: 'ok',
error_reason: null,
},
serving_account: { provider_id: 'anthropic', account: 'claude-team-a' },
latency_total_ms: 460,
fallback_count: 1,
cost_breakdown: { eval_usd: 0.0002, completion_usd: 0.01, total_usd: 0.0102 },
Expand All @@ -76,6 +79,16 @@ describe('toListItem', () => {
expect(row.decided_by).toBe('eval');
});

it('surfaces the served provider and final subscription account on list rows', () => {
const row = toListItem(rawRecord());
expect(row.served_provider).toBe('anthropic');
expect(row.serving_account).toEqual({ provider_id: 'anthropic', account: 'claude-team-a' });

const nonSubscription = toListItem(rawRecord({ serving_account: null }));
expect(nonSubscription.served_provider).toBe('anthropic');
expect(nonSubscription.serving_account).toBeNull();
});

it('falls back to "—" for key_prefix when the record carries none (never plaintext)', () => {
const row = toListItem(rawRecord({ key_prefix: null }));
expect(row.key_prefix).toBe('—');
Expand Down Expand Up @@ -136,6 +149,8 @@ describe('toDetail', () => {
expect(d.key_name).toBe('Production backend');
expect(d.requested_model).toBe('gpt-4o');
expect(d.final_model).toBe('premium'); // served model alias
expect(d.served_provider).toBe('anthropic');
expect(d.serving_account).toEqual({ provider_id: 'anthropic', account: 'claude-team-a' });
expect(d.lane).toBe('coding');
expect(d.status).toBe('ok');
expect(d.latency_ms).toBe(460);
Expand Down Expand Up @@ -254,6 +269,12 @@ describe('toDetail', () => {
});
// The ok attempt carries no detail.
expect(d.provider_attempts[1]?.error_detail ?? null).toBeNull();
expect(d.provider_attempts[1]?.provider).toBe('anthropic');
expect(d.provider_attempts[1]?.provider_model).toBe('claude-x');
expect(d.provider_attempts[1]?.serving_account).toEqual({
provider_id: 'anthropic',
account: 'claude-team-a',
});
});

it('maps a missing error_detail to null (legacy records)', () => {
Expand Down
73 changes: 71 additions & 2 deletions apps/admin/src/lib/api/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface RequestListItem {
complexity: string;
decided_by: 'rules' | 'eval' | 'default' | 'fallback'; // decision layer (classification stage)
lane: string;
served_provider: string | null;
serving_account: ServingAccountView | null;
final_model: string | null;
fallback_count: number; // execution fallback count (provider attempts - 1)
status: 'ok' | 'error';
Expand Down Expand Up @@ -80,6 +82,11 @@ export interface TokenUsageView {
total: number | null; // input + output when present; null when neither is measured
}

export interface ServingAccountView {
provider_id: string;
account: string;
}

// Redacted per-attempt upstream failure detail (admin-debug-error-detail).
// Present only for an attempt that failed at the upstream; the backend has
// already key-scrubbed `provider_raw` (Principle 7), so this is safe to display.
Expand All @@ -91,7 +98,9 @@ export interface AttemptErrorDetail {

export interface ProviderAttempt {
model: string;
provider: string;
provider: string | null;
provider_model: string | null;
serving_account: ServingAccountView | null;
outcome: 'success' | 'error' | 'timeout' | 'rate_limited' | 'circuit_open' | 'skipped';
skip_reason?: string;
latency_ms: number;
Expand All @@ -111,6 +120,8 @@ export interface RequestDetail {
key_prefix: string | null;
key_name: string | null;
requested_model: string | null; // what the client asked for
served_provider: string | null; // concrete provider that served the request
serving_account: ServingAccountView | null; // final subscription account, if any
final_model: string | null; // the served model alias (null = no provider served)
lane: string; // selected lane ('' on a legacy record)
status: 'ok' | 'error';
Expand Down Expand Up @@ -183,6 +194,7 @@ interface RawAttempt {
error_class?: string | null;
latency_ms?: number;
cost_usd?: number | null;
provider_name?: string | null;
provider_model?: string | null;
// Redacted upstream failure detail (admin-debug-error-detail). Null/absent on
// ok/skipped rows and on legacy records.
Expand Down Expand Up @@ -254,6 +266,10 @@ interface RawDecisionRecord {
status?: string;
error_reason?: string | null;
};
serving_account?: {
provider_id?: string | null;
account?: string | null;
} | null;
}

const BASE = '/admin/api/requests';
Expand Down Expand Up @@ -329,6 +345,42 @@ function attemptOutcome(a: RawAttempt): ProviderAttempt['outcome'] {
return 'error';
}

function nonEmptyString(v: unknown): string | null {
return typeof v === 'string' && v.length > 0 ? v : null;
}

function aliasPrefix(alias: string | null): string | null {
if (!alias) return null;
const slash = alias.indexOf('/');
return slash > 0 ? alias.slice(0, slash) : null;
}

function normalizeServingAccount(raw: RawDecisionRecord): ServingAccountView | null {
const providerId = nonEmptyString(raw.serving_account?.provider_id);
const account = nonEmptyString(raw.serving_account?.account);
return providerId && account ? { provider_id: providerId, account } : null;
}

function successfulAttempt(raw: RawDecisionRecord, attempts: RawAttempt[]): RawAttempt | null {
const finalAlias = nonEmptyString(raw.final?.model_alias);
if (finalAlias) {
const exact = attempts.find(
(a) => a.status === 'ok' && a.skipped !== true && a.alias === finalAlias,
);
if (exact) return exact;
}
return attempts.find((a) => a.status === 'ok' && a.skipped !== true) ?? null;
}

function servedProvider(raw: RawDecisionRecord, attempts: RawAttempt[]): string | null {
const attempt = successfulAttempt(raw, attempts);
return (
nonEmptyString(attempt?.provider_name) ??
aliasPrefix(nonEmptyString(raw.final?.model_alias)) ??
aliasPrefix(nonEmptyString(attempt?.alias))
);
}

// Project the recorded `usage` block -> the UI token view. Reads only the four
// recorded counts (Principle 1 — never recomputes upstream figures); DERIVES
// `nonCached` (input − cached, clamped ≥0) and `total` (input + output) for the
Expand Down Expand Up @@ -376,6 +428,7 @@ export function computeTtfbMs(raw: RawDecisionRecord): number | null {
// record does not carry are derived or safely defaulted; NEVER fabricated.
export function toListItem(raw: RawDecisionRecord): RequestListItem {
const attempts = Array.isArray(raw.provider_attempts) ? raw.provider_attempts : [];
const account = normalizeServingAccount(raw);
const status: RequestListItem['status'] = raw.final?.status === 'error' ? 'error' : 'ok';
const errorClass =
status === 'error'
Expand Down Expand Up @@ -404,6 +457,8 @@ export function toListItem(raw: RawDecisionRecord): RequestListItem {
complexity: raw.classifier?.complexity ?? '',
decided_by: normalizeDecidedBy(raw.classifier?.decided_by),
lane: raw.lane?.selected_lane ?? '',
served_provider: servedProvider(raw, attempts),
serving_account: account,
final_model: raw.final?.model_alias ?? null,
// Prefer the recorded value; fall back to deriving from attempts for legacy
// records (Principle 5: execution-stage count, distinct from decided_by).
Expand Down Expand Up @@ -473,6 +528,7 @@ export function toDetail(raw: RawDecisionRecord): RequestDetail {
const completion = sumCost(attempts);
const status = raw.final?.status === 'error' ? 'error' : 'ok';
const evalCacheHit = raw.classifier?.eval_cache_hit ?? null;
const account = normalizeServingAccount(raw);
return {
trace_id: String(raw.trace_id ?? raw.request_id ?? ''),
// Same source as the list "Time" column (created_at, flattened by the detail
Expand All @@ -486,6 +542,8 @@ export function toDetail(raw: RawDecisionRecord): RequestDetail {
typeof raw.key_prefix === 'string' && raw.key_prefix.length > 0 ? raw.key_prefix : null,
key_name: typeof raw.key_name === 'string' && raw.key_name.length > 0 ? raw.key_name : null,
requested_model: raw.requested_model ?? null,
served_provider: servedProvider(raw, attempts),
serving_account: account,
final_model: raw.final?.model_alias ?? null,
lane: raw.lane?.selected_lane ?? '',
status,
Expand Down Expand Up @@ -518,7 +576,18 @@ export function toDetail(raw: RawDecisionRecord): RequestDetail {
lane_candidates: Array.isArray(raw.lane?.candidate_chain) ? raw.lane.candidate_chain : [],
provider_attempts: attempts.map((a) => ({
model: String(a.alias ?? ''),
provider: String(a.provider_model ?? a.alias ?? ''),
provider:
nonEmptyString(a.provider_name) ??
aliasPrefix(nonEmptyString(a.alias)) ??
nonEmptyString(a.provider_model),
provider_model: nonEmptyString(a.provider_model),
serving_account:
account &&
a.status === 'ok' &&
a.skipped !== true &&
nonEmptyString(a.alias) === nonEmptyString(raw.final?.model_alias)
? account
: null,
outcome: attemptOutcome(a),
skip_reason: a.skip_reason ?? undefined,
latency_ms: typeof a.latency_ms === 'number' ? a.latency_ms : 0,
Expand Down
16 changes: 15 additions & 1 deletion apps/admin/src/lib/components/DecisionChain.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@
return String(value);
}
}

function accountTitle(account: RequestDetail['provider_attempts'][number]['serving_account']): string {
return account ? `${account.provider_id}/${account.account}` : '';
}
</script>

<div class="flex flex-col gap-4">
Expand Down Expand Up @@ -236,8 +240,18 @@
{#each detail.provider_attempts as a, i (i)}
<li data-testid="attempt-row" class="flex flex-col gap-1 text-sm">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-ink-strong">{a.provider}</span>
<span class="font-mono text-ink-strong">{a.provider ?? '—'}</span>
{#if a.serving_account}
<span class="badge-neutral" title={accountTitle(a.serving_account)}
>{a.serving_account.account}</span
>
{/if}
<span class="text-ink-muted">{a.model}</span>
{#if a.provider_model && a.provider_model !== a.model}
<span class="text-ink-muted" title={$t('Provider model')}
>{$t('wire:')} {a.provider_model}</span
>
{/if}
<span class={outcomeBadge(a.outcome)} title={a.outcome}
>{$t(attemptCodeLabel(a.outcome))}</span
>
Expand Down
9 changes: 9 additions & 0 deletions apps/admin/src/lib/components/DecisionChain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ function detail(overrides: Partial<RequestDetail> = {}): RequestDetail {
key_prefix: 'helm_live_ab12',
key_name: null,
requested_model: 'gpt-4o',
served_provider: 'anthropic',
serving_account: { provider_id: 'anthropic', account: 'claude-team-a' },
final_model: 'claude-x',
lane: 'premium',
status: 'ok',
Expand All @@ -45,6 +47,8 @@ function detail(overrides: Partial<RequestDetail> = {}): RequestDetail {
{
model: 'gpt-x',
provider: 'openai',
provider_model: 'gpt-x',
serving_account: null,
outcome: 'error',
latency_ms: 120,
error_class: 'upstream_error',
Expand All @@ -57,13 +61,17 @@ function detail(overrides: Partial<RequestDetail> = {}): RequestDetail {
{
model: 'claude-x',
provider: 'anthropic',
provider_model: 'claude-x',
serving_account: { provider_id: 'anthropic', account: 'claude-team-a' },
outcome: 'success',
latency_ms: 340,
error_detail: null,
},
{
model: 'small-x',
provider: 'local',
provider_model: 'small-x',
serving_account: null,
outcome: 'skipped',
skip_reason: 'capability_unsatisfiable',
latency_ms: 0,
Expand Down Expand Up @@ -237,6 +245,7 @@ describe('DecisionChain', () => {
expect(rows[0]).toHaveTextContent(/error/i);
expect(rows[0]).toHaveTextContent('120');
expect(rows[1]).toHaveTextContent(/success/i);
expect(rows[1]).toHaveTextContent('claude-team-a');
expect(rows[2]).toHaveTextContent(/skipped/i);
// Codes render as human labels; the raw code is preserved in the title tooltip.
expect(rows[2]).toHaveTextContent('No compatible model');
Expand Down
Loading
Loading