Skip to content
Open
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
34 changes: 34 additions & 0 deletions packages/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ import type {
CreatePersonalAccessTokenRequest,
CreatePersonalAccessTokenResponse,
RuntimeUsage,
ProviderUsageResponse,
RuntimeProviderUsageListResponse,
IssueUsageSummary,
RuntimeHourlyActivity,
RuntimeUsageByAgent,
Expand Down Expand Up @@ -334,6 +336,10 @@ import {
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
ProviderUsageResponseSchema,
EMPTY_PROVIDER_USAGE_RESPONSE,
RuntimeProviderUsageListSchema,
EMPTY_RUNTIME_PROVIDER_USAGE_LIST,
SearchIssuesResponseSchema,
SearchProjectsResponseSchema,
SearchIndexManifestSchema,
Expand Down Expand Up @@ -2447,6 +2453,34 @@ export class ApiClient {
);
}

async getRuntimeProviderUsage(runtimeId: string): Promise<ProviderUsageResponse> {
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/provider-usage`,
);
return parseWithFallback(
raw,
ProviderUsageResponseSchema,
EMPTY_PROVIDER_USAGE_RESPONSE,
{ endpoint: "GET /api/runtimes/:id/provider-usage" },
);
}

async listRuntimeProviderUsage(
runtimeIds: readonly string[],
): Promise<RuntimeProviderUsageListResponse> {
const search = new URLSearchParams();
search.set("runtime_ids", runtimeIds.join(","));
const raw = await this.fetch<unknown>(
`/api/runtimes/provider-usage?${search}`,
);
return parseWithFallback(
raw,
RuntimeProviderUsageListSchema,
EMPTY_RUNTIME_PROVIDER_USAGE_LIST,
{ endpoint: "GET /api/runtimes/provider-usage" },
);
}

async getRuntimeUsage(
runtimeId: string,
params?: { days?: number; tz?: string },
Expand Down
62 changes: 62 additions & 0 deletions packages/core/api/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ import {
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
ProviderUsageResponseSchema,
EMPTY_PROVIDER_USAGE_RESPONSE,
RuntimeProviderUsageListSchema,
EMPTY_RUNTIME_PROVIDER_USAGE_LIST,
SendChatMessageResponseSchema,
SquadListSchema,
SquadSchema,
Expand Down Expand Up @@ -1184,6 +1188,64 @@ describe("dashboard + runtime usage schema drift", () => {
expect(RuntimeUsageByHourListSchema.parse([{ hour: 9 }])[0]?.model).toBe("");
});

it("falls back when a provider-usage payload is malformed and keeps a partial snapshot", () => {
expect(
parseWithFallback(
{ providers: "nope" },
ProviderUsageResponseSchema,
EMPTY_PROVIDER_USAGE_RESPONSE,
{ endpoint: "GET /api/runtimes/:id/provider-usage" },
),
).toEqual(EMPTY_PROVIDER_USAGE_RESPONSE);
expect(
parseWithFallback(
null,
ProviderUsageResponseSchema,
EMPTY_PROVIDER_USAGE_RESPONSE,
{ endpoint: "GET /api/runtimes/:id/provider-usage" },
).providers,
).toEqual([]);

const partial = ProviderUsageResponseSchema.parse({
providers: [{ provider: "claude" }, { plan_name: "Pro", windows: [{ percent_used: 12 }] }],
});
expect(partial.providers[0]?.windows).toBeUndefined();
expect(partial.providers[0]?.provider).toBe("claude");
expect(partial.providers[1]?.provider).toBe("");
expect(partial.providers[1]?.windows?.[0]?.id).toBe("");
expect(partial.providers[1]?.windows?.[0]?.percent_used).toBe(12);
});

it("falls back when a runtime provider-usage list is malformed and keeps a partial row", () => {
expect(
parseWithFallback(
{ runtimes: "nope" },
RuntimeProviderUsageListSchema,
EMPTY_RUNTIME_PROVIDER_USAGE_LIST,
{ endpoint: "GET /api/runtimes/provider-usage" },
),
).toEqual(EMPTY_RUNTIME_PROVIDER_USAGE_LIST);
expect(
parseWithFallback(
null,
RuntimeProviderUsageListSchema,
EMPTY_RUNTIME_PROVIDER_USAGE_LIST,
{ endpoint: "GET /api/runtimes/provider-usage" },
).runtimes,
).toEqual([]);

const partial = RuntimeProviderUsageListSchema.parse({
runtimes: [
{ providers: [{ provider: "claude", windows: [{ percent_used: 38 }] }] },
{ runtime_id: "rt-2" },
],
});
expect(partial.runtimes[0]?.runtime_id).toBe("");
expect(partial.runtimes[0]?.providers[0]?.windows?.[0]?.percent_used).toBe(38);
expect(partial.runtimes[1]?.runtime_id).toBe("rt-2");
expect(partial.runtimes[1]?.providers).toEqual([]);
});

it("defaults a missing provider to \"\" so an older server's rows still price by bare model", () => {
// provider was added for cross-provider model disambiguation; a server
// predating it omits the field. The schema must fill "" (→ bare-model
Expand Down
32 changes: 32 additions & 0 deletions packages/core/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1840,6 +1840,38 @@ const RuntimeUsageByHourSchema = z.object({

export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema);

// Plan-limit snapshots reported by the local daemon. Numbers and strings stay
// optional so an older or partial payload degrades instead of failing the
// whole response. Unknown fields pass through `.loose()`.
const ProviderUsageWindowSchema = z.object({
id: z.string().default(""),
percent_used: z.number().optional(),
resets_at: z.string().optional(),
}).loose();

const ProviderUsageSnapshotSchema = z.object({
provider: z.string().default(""),
plan_name: z.string().optional(),
collected_at: z.string().optional(),
reason_code: z.string().optional(),
windows: z.array(ProviderUsageWindowSchema).optional(),
}).loose();

export const ProviderUsageResponseSchema = z.object({
providers: z.array(ProviderUsageSnapshotSchema).default([]),
}).loose();

export const EMPTY_PROVIDER_USAGE_RESPONSE = { providers: [] };

export const RuntimeProviderUsageListSchema = z.object({
runtimes: z.array(z.object({
runtime_id: z.string().default(""),
providers: z.array(ProviderUsageSnapshotSchema).default([]),
}).loose()).default([]),
}).loose();

export const EMPTY_RUNTIME_PROVIDER_USAGE_LIST = { runtimes: [] };

// ---------------------------------------------------------------------------
// Agent task responses. The base object stays loose so daemon/runtime fields
// can drift while task-list consumers still validate the fields they render.
Expand Down
26 changes: 26 additions & 0 deletions packages/core/runtimes/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export const runtimeKeys = {
// by-hour now follows the viewer's tz, like the other reports.
usageByHour: (rid: string, days: number, tz: string) =>
["runtimes", "usage", "by-hour", rid, days, tz] as const,
providerUsage: (wsId: string, runtimeId: string) =>
[...runtimeKeys.all(wsId), "provider-usage", runtimeId] as const,
providerUsageList: (wsId: string, runtimeIds: readonly string[]) =>
[...runtimeKeys.all(wsId), "provider-usage-list", runtimeIds] as const,
};

// `tz` is the viewer's IANA name — all reports follow the viewer's tz.
Expand Down Expand Up @@ -39,6 +43,28 @@ export function runtimeUsageByAgentOptions(
});
}

export function runtimeProviderUsageOptions(wsId: string, runtimeId: string) {
return queryOptions({
queryKey: runtimeKeys.providerUsage(wsId, runtimeId),
queryFn: () => api.getRuntimeProviderUsage(runtimeId),
enabled: wsId.length > 0 && runtimeId.length > 0,
staleTime: 60 * 1000,
});
}

export function runtimeProviderUsageListOptions(
wsId: string,
runtimeIds: readonly string[],
) {
const ids = [...runtimeIds].sort();
return queryOptions({
queryKey: runtimeKeys.providerUsageList(wsId, ids),
queryFn: () => api.listRuntimeProviderUsage(ids),
enabled: wsId.length > 0 && ids.length > 0,
staleTime: 60 * 1000,
});
}

export function runtimeUsageByHourOptions(runtimeId: string, days: number, tz: string) {
return queryOptions({
queryKey: runtimeKeys.usageByHour(runtimeId, days, tz),
Expand Down
32 changes: 32 additions & 0 deletions packages/core/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,38 @@ export interface RuntimeHourlyActivity {
count: number;
}

// Derived plan-limit snapshot for one provider on a runtime. Older backends
// omit the endpoint entirely; fields stay optional so a partial payload still
// renders. This is not task token accounting.
export interface ProviderUsageWindow {
id: string;
percent_used?: number;
resets_at?: string;
}

export interface ProviderUsageSnapshot {
provider: string;
plan_name?: string;
collected_at?: string;
reason_code?: string;
windows?: ProviderUsageWindow[];
}

export interface ProviderUsageResponse {
providers: ProviderUsageSnapshot[];
}

// One machine's plan-limit snapshots, keyed by the runtime that stored them.
// The snapshot objects are the same shape as ProviderUsageResponse.providers.
export interface RuntimeProviderUsageListItem {
runtime_id: string;
providers: ProviderUsageSnapshot[];
}

export interface RuntimeProviderUsageListResponse {
runtimes: RuntimeProviderUsageListItem[];
}

// One (agent, provider, model) row of the "Cost by agent" tab on the runtime
// detail page. provider + model stay on the wire because cost is computed
// client-side from a per-model pricing table (provider disambiguates bare
Expand Down
5 changes: 5 additions & 0 deletions packages/core/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export type {
RuntimeHourlyActivity,
RuntimeUsageByAgent,
RuntimeUsageByHour,
ProviderUsageWindow,
ProviderUsageSnapshot,
ProviderUsageResponse,
RuntimeProviderUsageListItem,
RuntimeProviderUsageListResponse,
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
Expand Down
6 changes: 5 additions & 1 deletion packages/views/agents/components/agent-overview-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,11 @@ export function AgentOverviewPane({
className={cn(PAGE_RAIL, PAGE_GUTTER, "py-4 sm:py-6")}
>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<ActivityTab agent={agent} showPerformance={false} />
<ActivityTab
agent={agent}
showPerformance={false}
provider={runtime?.provider}
/>
<AgentOverviewSummary
agent={agent}
runtime={runtime}
Expand Down
16 changes: 15 additions & 1 deletion packages/views/agents/components/tabs/activity-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { AttributionBadge } from "../../../issues/components/attribution-badge";
import { taskStatusConfig } from "../../config";
import { cancellationActorLabel, cancelReasonLabel, failureReasonLabel } from "./task-failure";
import { Sparkline } from "../sparkline";
import { ProviderUsageBlock } from "../../../runtimes/components/provider-usage-block";
import { useT, useTimeAgo } from "../../../i18n";

// Reveal cached rows in small batches and fetch another page only on demand.
Expand All @@ -51,6 +52,8 @@ const RECENT_SKELETON_ROWS = 4;
interface ActivityTabProps {
agent: Agent;
showPerformance?: boolean;
/** Protocol family of the agent's runtime. Other vendors stay hidden. */
provider?: string;
}

/**
Expand All @@ -64,7 +67,11 @@ interface ActivityTabProps {
* "Now" and performance reuse workspace projections. Recent work loads
* bounded history pages on demand; opening the tab never fetches all runs.
*/
export function ActivityTab({ agent, showPerformance = true }: ActivityTabProps) {
export function ActivityTab({
agent,
showPerformance = true,
provider,
}: ActivityTabProps) {
const wsId = useWorkspaceId();

const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
Expand Down Expand Up @@ -177,6 +184,13 @@ export function ActivityTab({ agent, showPerformance = true }: ActivityTabProps)

return (
<div className="flex min-w-0 flex-col gap-6">
{agent.runtime_id ? (
<ProviderUsageBlock
wsId={wsId}
runtimeId={agent.runtime_id}
provider={provider}
/>
) : null}
<NowSection tasks={activeTasks} issueMap={issueMap} agent={agent} />
{showPerformance && (
<Last30dSection activity={activity} avgDurationMs={avgDurationMs} />
Expand Down
48 changes: 47 additions & 1 deletion packages/views/locales/en/runtimes.json
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@
"col_owner": "Owner",
"col_agents": "Agents",
"col_cost": "Cost · 7d",
"col_usage": "Usage",
"col_cli": "CLI",
"cost_delta_flat": "flat",
"cli_managed_badge": "Desktop",
Expand Down Expand Up @@ -464,6 +465,51 @@
"table_output": "Output",
"table_cache_r": "Cache R",
"table_cache_w": "Cache W",
"no_data": "No usage data yet"
"no_data": "No usage data yet",
"provider_limits": {
"title": "Plan usage",
"description": "Percent used on this runtime's signed-in plan.",
"empty": "No local session on this machine.",
"waiting": "No plan usage reported from this machine yet.",
"plan": "Plan",
"collected": "Collected {{time}}",
"resets": "Resets {{time}}",
"used": "{{pct}}% used",
"provider_claude": "Claude Code",
"provider_cursor": "Cursor",
"provider_codex": "Codex",
"provider_copilot": "GitHub Copilot",
"provider_antigravity": "Antigravity",
"provider_grok": "Grok",
"provider_kimi": "Kimi",
"provider_kiro": "Kiro",
"provider_opencode": "OpenCode",
"window_session": "Session",
"window_weekly_all": "Weekly",
"window_auto": "Auto",
"window_api": "API",
"window_primary": "Primary",
"window_secondary": "Secondary",
"window_premium_interactions": "Premium requests",
"window_chat": "Chat",
"window_completions": "Completions",
"window_rolling": "5-hour",
"window_weekly": "Weekly",
"window_monthly": "Monthly",
"window_credits": "Credits",
"window_bonus": "Bonus",
"window_gemini_hourly": "Gemini 5-hour",
"window_gemini_weekly": "Gemini weekly",
"window_third_party_hourly": "Claude and GPT 5-hour",
"window_third_party_weekly": "Claude and GPT weekly",
"reason_not_logged_in": "Not signed in on this machine.",
"reason_api_key_only": "This Codex login is an API key, so plan limits are not available.",
"reason_unauthorized": "The local session was rejected. Sign in again in the CLI or editor.",
"reason_credential_expired": "The local sign-in has expired. Open the app to renew it.",
"reason_cli_unavailable": "The CLI is not installed on this machine.",
"reason_session_unavailable": "No local editor session was found on this machine.",
"reason_unsupported": "Plan limits cannot be read on this machine.",
"reason_unknown": "Plan limits are unavailable."
}
}
}
Loading
Loading