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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Group Fusion adviser and synthesizer usage into one expandable request with shared lineage, aggregate cost, end-to-end latency, and preserved billable-call detail.
- Add an on-demand `clawrouter/fusion` chat model with parallel local or hosted advisers, a policy-native final synthesizer, OpenAI-compatible Ollama/LM Studio routing, bounded fail-open orchestration, and web-console configuration.
- Add accessible 30-day request and provider analytics to Dashboard and Usage while preserving rolling usage totals across mixed-version deployments.
- Reject non-object OpenAI, manifest, and native proxy request bodies before routing or budget reservation.
Expand Down
27 changes: 27 additions & 0 deletions admin/src/demo-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,33 @@ export function demoUsageSnapshot(): UsageSnapshot {
const now = Date.now();
const summary = { requestCount: 1284, successCount: 1247, errorCount: 37, inputTokens: 1_482_402, outputTokens: 382_151, totalTokens: 1_864_553, actualCostMicros: 8_432_100 };
const events: UsageAuditEvent[] = [
demoUsageEvent("usage_fusion_final", now - 18_000, "admin@example.com", "maintainer_models", "openai", "llm.chat", "openai/gpt-4.1-mini", 200, 690, 1900, "success", 1512, {
request_id: "req_fusion_7",
compound_request_id: "req_fusion_7",
compound_request_stage: "fusion_synthesizer",
compound_request_index: null,
compound_request_size: 3,
compound_request_started_at_ms: now - 19_100,
cost_basis: "manifest_pricing",
}),
demoUsageEvent("usage_fusion_adviser_2", now - 18_420, "admin@example.com", "maintainer_models", "openai", "llm.chat", "openai/gpt-4.1-mini", 503, 244, 0, "provider_error", 0, {
request_id: "fusion-adviser-2-demo",
compound_request_id: "req_fusion_7",
compound_request_stage: "fusion_adviser",
compound_request_index: 2,
compound_request_size: 3,
compound_request_started_at_ms: now - 19_100,
cost_basis: "manifest_pricing",
}),
demoUsageEvent("usage_fusion_adviser_1", now - 18_510, "admin@example.com", "maintainer_models", "local-openai", "llm.chat", "local/qwen3:8b", 200, 410, 0, "success", 846, {
request_id: "fusion-adviser-1-demo",
compound_request_id: "req_fusion_7",
compound_request_stage: "fusion_adviser",
compound_request_index: 1,
compound_request_size: 3,
compound_request_started_at_ms: now - 19_100,
cost_basis: "manifest_pricing",
}),
demoUsageEvent("usage_6", now - 26_000, "admin@example.com", "maintainer_models", "openai", "llm.responses", "gpt-5.4", 200, 842, 1000, "success", 1814, {
agent_id: "codex/reviewer",
parent_agent_id: "codex/orchestrator",
Expand Down
59 changes: 45 additions & 14 deletions admin/src/screens/users-usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function UsageScreen({ keys, credentials, services, overview, tenants, us
const [retainedContent, setRetainedContent] = useState<RetainedRequestContent | null>(null);
const [contentError, setContentError] = useState("");
const [contentLoading, setContentLoading] = useState(false);
const [expandedRequestId, setExpandedRequestId] = useState<string | null>(null);
const contentFeedbackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentLoading || contentError || retainedContent) contentFeedbackRef.current?.scrollIntoView({ block: "nearest" });
Expand Down Expand Up @@ -92,6 +93,7 @@ export function UsageScreen({ keys, credentials, services, overview, tenants, us
const untrackedRows = rows.filter((row) => row.enabled && row.budget.ledger === "untracked");
const exhaustedRows = rows.filter((row) => row.enabled && row.budget.configured && row.budget.remainingMicros !== undefined && row.budget.remainingMicros !== null && row.budget.remainingMicros <= 0);
const ledgerFailureRows = rows.filter((row) => row.enabled && (row.budget.ledger === "unavailable" || row.budget.ledger === "invalid_policy"));
const requestGroups = usageEventGroups(usage.events);
return (
<div className="usageCanvas">
<section className="usageSummaryGrid" aria-label="Usage summary">
Expand Down Expand Up @@ -140,26 +142,32 @@ export function UsageScreen({ keys, credentials, services, overview, tenants, us
</div> : null}

<section className="analyticsPanel usageTablePanel">
<div className="tableSectionHeader"><div><strong>Recent requests</strong><span>{usage.events.length} most recent audit events</span></div><span>{usageLoaded ? usage.ledger : "unavailable"}</span></div>
<div className="tableSectionHeader"><div><strong>Recent requests</strong><span>{requestGroups.length} requests · {usage.events.length} billable calls</span></div><span>{usageLoaded ? usage.ledger : "unavailable"}</span></div>
<EntityTable
columns={["time", "identity", "service", "operation", "outcome", "content", "cost"]}
columnTemplate="92px minmax(170px, 1.2fr) minmax(145px, 1fr) minmax(150px, 1fr) 104px 90px 74px"
rows={usage.events.map((event) => {
const service = serviceByProvider.get(event.provider);
rows={requestGroups.map((group) => {
const event = group.primary;
const service = group.compound ? undefined : serviceByProvider.get(event.provider);
const verifiedIdentity = event.principal_id ?? event.credential_id ?? event.policy_id ?? event.tenant_id;
const agentIdentity = event.agent_id ? `agent ${event.agent_id}` : event.auth_type ?? "authenticated";
const agentContext = [event.parent_agent_id && `parent ${event.parent_agent_id}`, event.client && `client ${event.client}`, event.project_id && `project ${event.project_id}`, event.session_id && `session ${event.session_id}`].filter(Boolean).join(" · ");
return {
id: event.id,
id: group.id,
cells: [
<span className="auditTime" title={formatTimestamp(event.occurred_at_ms, true)}>{formatTimestamp(event.occurred_at_ms)}</span>,
<span className="auditTime" title={formatTimestamp(group.occurredAtMs, true)}>{formatTimestamp(group.occurredAtMs)}</span>,
<span className="auditIdentity" title={[`authenticated ${verifiedIdentity}`, agentIdentity, agentContext].filter(Boolean).join(" · ")}><strong>{verifiedIdentity}</strong><small>{agentIdentity}</small>{agentContext ? <small>{agentContext}</small> : null}</span>,
<EntityName brandIcon={service?.brandIcon} icon={ServerCog} title={service?.name ?? event.provider} subtitle={event.provider} />,
<span className="auditOperation"><strong>{event.capability ?? event.type}</strong><small>{[event.model, event.cost_basis].filter(Boolean).join(" · ") || event.request_id || "request"}</small></span>,
<Status label={event.status_code ? `${event.status_code} ${event.status}` : event.status} tone={usageEventTone(event)} />,
event.content_retained ? <button type="button" className="tableAction" onClick={() => void inspectContent(event)}>View</button> : <span title={event.duration_ms ? `Latency ${formatDuration(event.duration_ms)}` : undefined}>not stored</span>,
formatMicros(event.actual_cost_micros),
<EntityName brandIcon={service?.brandIcon} icon={ServerCog} title={group.compound ? "ClawRouter Fusion" : service?.name ?? event.provider} subtitle={group.compound ? `${group.events.length} model calls` : event.provider} />,
group.compound
? <button type="button" className="compoundRequestToggle" aria-expanded={expandedRequestId === group.id} onClick={() => setExpandedRequestId((current) => current === group.id ? null : group.id)}><strong>Fusion ensemble</strong><small>{group.complete ? `${group.events.length} calls` : `${group.events.length}/${group.expectedCallCount} calls · partial`}</small></button>
: <span className="auditOperation"><strong>{event.capability ?? event.type}</strong><small>{[event.model, event.cost_basis].filter(Boolean).join(" · ") || event.request_id || "request"}</small></span>,
<Status label={group.compound ? `${group.successCount}/${group.events.length} succeeded` : event.status_code ? `${event.status_code} ${event.status}` : event.status} tone={usageEventTone(event)} />,
group.compound
? <span title={group.durationMs != null ? `End-to-end latency ${formatDuration(group.durationMs)}` : undefined}>{group.events.filter((item) => item.content_retained).length} stored</span>
: event.content_retained ? <button type="button" className="tableAction" onClick={() => void inspectContent(event)}>View</button> : <span title={event.duration_ms ? `Latency ${formatDuration(event.duration_ms)}` : undefined}>not stored</span>,
`${group.complete ? "" : "≥"}${formatMicros(group.actualCostMicros)}`,
],
detail: group.compound && expandedRequestId === group.id ? <CompoundRequestCalls group={group} onInspect={inspectContent} /> : undefined,
};
})}
/>
Expand All @@ -174,6 +182,25 @@ export function UsageScreen({ keys, credentials, services, overview, tenants, us
);
}

function CompoundRequestCalls({ group, onInspect }: { group: UsageEventGroup; onInspect: (event: UsageAuditEvent) => Promise<void> }) {
return (
<div className="compoundRequest" aria-label="Fusion billable calls">
<div className="compoundRequestHeader"><strong>{group.complete ? "Billable call detail" : "Partial billable call detail"}</strong><span>{group.durationMs != null ? `${group.complete ? "End-to-end" : "Visible span"} ${formatDuration(group.durationMs)}` : "Latency unavailable"} · {group.complete ? formatMicros(group.actualCostMicros) : `at least ${formatMicros(group.actualCostMicros)}`}</span></div>
<div className="compoundRequestCalls">
{group.events.map((event) => <div key={event.id}>
<span><strong>{compoundStage(event)}</strong><small>{event.provider} · {event.model ?? event.capability ?? "request"}</small></span>
<span><small>{event.duration_ms != null ? formatDuration(event.duration_ms) : "—"} · {formatMicros(event.actual_cost_micros)}</small>{event.content_retained ? <button type="button" className="tableAction" onClick={() => void onInspect(event)}>View</button> : null}</span>
</div>)}
</div>
{!group.complete ? <p className="compoundRequestWarning">This recent-event window contains {group.events.length} of {group.expectedCallCount} calls. Totals exclude older calls outside the window.</p> : null}
</div>
);
}

function compoundStage(event: UsageAuditEvent) {
return event.compound_request_stage === "fusion_synthesizer" ? "Synthesizer" : `Adviser ${event.compound_request_index ?? ""}`.trim();
}

export function Metric({ label, value, meta }: { label: string; value: string; meta: string }) {
return <div className="metric"><span>{label}</span><strong>{value}</strong><small>{meta}</small></div>;
}
Expand Down Expand Up @@ -211,16 +238,19 @@ export function UsageHealth({ row }: { row: AdminUsageRow }) {
return <Status label="healthy" tone="active" />;
}

export function EntityTable({ columns, columnTemplate, rows }: { columns: string[]; columnTemplate?: string; rows: Array<{ id: string; active?: boolean; onClick?: () => void; cells: React.ReactNode[] }> }) {
export function EntityTable({ columns, columnTemplate, rows }: { columns: string[]; columnTemplate?: string; rows: Array<{ id: string; active?: boolean; onClick?: () => void; cells: React.ReactNode[]; detail?: React.ReactNode }> }) {
return (
<div className="entityTable" style={{ "--cols": columns.length, "--columns": columnTemplate } as React.CSSProperties}>
<div className="tableHead">{columns.map((column) => <span key={column}>{column}</span>)}</div>
<div className="tableBody">{rows.map((row) => {
const content = row.cells.map((cell, index) => <span key={index} data-label={columns[index]}>{cell}</span>);
const className = `tableRow${row.active ? " selected" : ""}${row.onClick ? " interactive" : ""}`;
return row.onClick
? <button type="button" key={row.id} className={className} onClick={row.onClick}>{content}</button>
: <div key={row.id} className={className}>{content}</div>;
return <React.Fragment key={row.id}>
{row.onClick
? <button type="button" className={className} onClick={row.onClick}>{content}</button>
: <div className={className}>{content}</div>}
{row.detail ? <div className="tableRowDetail">{row.detail}</div> : null}
</React.Fragment>;
})}</div>
</div>
);
Expand All @@ -230,5 +260,6 @@ import { Activity, CalendarDays, KeyRound, Plus, Search, ServerCog, ShieldCheck,
import { bindingKey, effectiveAccess, errorMessage, policyUsageFallback, tenantSummaryFallback } from "../domain";
import { EntityName, InlineError, InlineNote, InspectorHeader, PanelTitle, Status, kindLabel } from "../components";
import { ProviderUsageChart, TrafficAreaChart } from "../analytics-charts";
import { usageEventGroups, type UsageEventGroup } from "../usage-analytics";
import { budgetPercent, effectiveProviderCount, formatBudget, formatCount, formatDuration, formatMicros, formatTimestamp, providerBrandIcon, readyCount, request, usageEventTone, usagePolicyId } from "../ui-helpers";
import type { AccessForm,AccessPolicy,AccessRole,AccessTab,AccessUser,AdminOverview,AdminTenantSummary,AdminUsageRow,AssignmentRule,AssignmentRuleForm,BindingForm,BrandIcon,BudgetStatus,ContentRetention,CredentialForm,EntitlementsResponse,IconComponent,OutcomeTone,PlaygroundForm,PlaygroundHttpResponse,PlaygroundTurn,PolicyBinding,PolicyForm,ProviderAccess,ProviderConnection,ProviderReadiness,ProviderResponse,ProviderRow,ProviderUsageSummary,ProxyCredential,RefreshOptions,RetainedRequestContent,RouteCatalog,ServiceItem,ServiceOutcome,SessionResponse,UpstreamGrant,UpstreamGrantForm,UsageAuditEvent,UsageSnapshot,UsageSummary,View } from "../ui-types";
89 changes: 89 additions & 0 deletions admin/src/styles/usage.css
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,95 @@
white-space: nowrap;
}

.compoundRequestToggle {
display: grid;
width: 100%;
gap: 2px;
padding: 0;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}

.compoundRequestToggle strong {
color: var(--ink);
font-size: 10.5px;
font-weight: 600;
}

.compoundRequestToggle small,
.compoundRequestCalls small {
color: var(--muted);
font-family: var(--font-mono);
font-size: 9.5px;
}

.usageTablePanel .tableRowDetail {
border-bottom: 1px solid var(--line);
background: var(--surface-raised);
}

.compoundRequest {
padding: 12px 16px 14px;
}

.compoundRequestHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}

.compoundRequestHeader strong {
color: var(--ink);
font-size: 11px;
}

.compoundRequestHeader span,
.compoundRequestWarning {
color: var(--muted);
font-family: var(--font-mono);
font-size: 9.5px;
}

.compoundRequestCalls {
display: grid;
width: 100%;
border: 1px solid var(--line);
background: var(--surface-raised);
box-shadow: var(--shadow-raised);
}

.compoundRequestWarning {
margin: 8px 0 0;
}

.compoundRequestCalls > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
}

.compoundRequestCalls > div:last-child {
border-bottom: 0;
}

.compoundRequestCalls > div > span {
display: grid;
gap: 2px;
min-width: 0;
}

.compoundRequestCalls > div > span:last-child {
justify-items: end;
white-space: nowrap;
}

.budgetUsage {
display: grid;
gap: 7px;
Expand Down
45 changes: 44 additions & 1 deletion admin/src/usage-analytics.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,50 @@
import type { UsageDailySummary, UsageSnapshot, UsageSummary } from "./ui-types";
import type { UsageAuditEvent, UsageDailySummary, UsageSnapshot, UsageSummary } from "./ui-types";

export const usageDayMs = 86_400_000;

export interface UsageEventGroup {
id: string;
compound: boolean;
events: UsageAuditEvent[];
primary: UsageAuditEvent;
occurredAtMs: number;
durationMs: number | null;
actualCostMicros: number;
successCount: number;
expectedCallCount: number;
complete: boolean;
}

export function usageEventGroups(events: UsageAuditEvent[]): UsageEventGroup[] {
const grouped = new Map<string, UsageAuditEvent[]>();
const order: string[] = [];
for (const event of events) {
const key = event.compound_request_id ? `compound:${event.compound_request_id}` : `event:${event.id}`;
if (!grouped.has(key)) order.push(key);
grouped.set(key, [...(grouped.get(key) ?? []), event]);
}
return order.map((key) => summarizeUsageEvents(key, grouped.get(key) ?? []));
}

function summarizeUsageEvents(key: string, events: UsageAuditEvent[]): UsageEventGroup {
const primary = events.find((event) => event.compound_request_stage === "fusion_synthesizer") ?? events[0];
if (!primary) throw new Error("usage event group cannot be empty");
const startedAt = Math.min(...events.map((event) => event.compound_request_started_at_ms ?? event.occurred_at_ms - Math.max(0, event.duration_ms ?? 0)));
const occurredAtMs = Math.max(...events.map((event) => event.occurred_at_ms));
return {
id: key,
compound: Boolean(primary.compound_request_id),
events: [...events].sort((left, right) => (left.compound_request_index ?? Number.MAX_SAFE_INTEGER) - (right.compound_request_index ?? Number.MAX_SAFE_INTEGER)),
primary,
occurredAtMs,
durationMs: events.some((event) => event.duration_ms != null) ? occurredAtMs - startedAt : null,
actualCostMicros: events.reduce((total, event) => total + event.actual_cost_micros, 0),
successCount: events.filter((event) => event.status === "success").length,
expectedCallCount: Math.max(...events.map((event) => event.compound_request_size ?? events.length)),
complete: !primary.compound_request_id || events.length >= Math.max(...events.map((event) => event.compound_request_size ?? events.length)),
};
}

export function usageTimeline(snapshot: UsageSnapshot, days = 30, now = Date.now()): UsageDailySummary[] {
const today = Math.floor(now / usageDayMs) * usageDayMs;
const firstDay = today - (days - 1) * usageDayMs;
Expand Down
Loading
Loading