From a86cc65568ea275814842240748f02bc64862552 Mon Sep 17 00:00:00 2001 From: catwithtudou <949812478@qq.com> Date: Sun, 7 Jun 2026 15:18:42 +0800 Subject: [PATCH] feat: add Codex usage telemetry --- src/server/routes/usage.ts | 351 +++++++++++++++++++--- tests/server/routes/usage.test.ts | 232 ++++++++++++++ web/src/app/usage/index.tsx | 482 +++++++++++++++++++++++------- web/src/lib/api/hooks.ts | 89 ++++++ web/src/lib/api/index.ts | 5 + 5 files changed, 1013 insertions(+), 146 deletions(-) create mode 100644 tests/server/routes/usage.test.ts diff --git a/src/server/routes/usage.ts b/src/server/routes/usage.ts index e07ab5f..363fc17 100644 --- a/src/server/routes/usage.ts +++ b/src/server/routes/usage.ts @@ -1,9 +1,238 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + import { Hono } from "hono"; -import { createLogger } from "@/shared"; +import { config, createLogger } from "@/shared"; const logger = createLogger("usage"); +/** + * Token usage values emitted by Codex token_count events. + */ +export interface CodexTokenUsage { + input_tokens: number; + cached_input_tokens: number; + output_tokens: number; + reasoning_output_tokens: number; + total_tokens: number; +} + +/** + * Codex rate-limit window telemetry derived from local session logs. + */ +export interface CodexRateLimitWindow { + used_percent: number; + window_minutes: number | null; + resets_at: string | null; +} + +/** + * Local Codex usage telemetry inferred from Codex session JSONL files. + */ +export interface CodexUsageTelemetry { + captured_at: string; + codex_home: string; + plan_type: string | null; + rate_limits: { + primary: CodexRateLimitWindow | null; + secondary: CodexRateLimitWindow | null; + rate_limit_reached_type: string | null; + }; + token_usage: { + total: CodexTokenUsage | null; + last: CodexTokenUsage | null; + model_context_window: number | null; + }; +} + +interface ClaudeUsage { + five_hour: { + utilization: number; + resets_at: string | null; + }; + seven_day: { + utilization: number; + resets_at: string; + }; + extra_usage: + | { + is_enabled: true; + monthly_limit: number; + used_credits: number; + utilization: number; + } + | { + is_enabled: false; + monthly_limit: null; + used_credits: null; + utilization: null; + }; +} + +/** + * Options for wiring usage routes in tests or alternate runtime contexts. + */ +export interface UsageRoutesOptions { + /** Returns the currently configured default agent type. */ + getActiveAgentType?: () => string; + /** Returns the Codex home directory to inspect for local telemetry. */ + getCodexHome?: () => string; + /** Reads Claude usage data from the underlying provider. */ + queryClaudeUsage?: typeof queryClaudeUsage; + /** Reads local Codex usage telemetry. */ + queryCodexUsage?: typeof queryCodexUsage; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function dateStringOrNull(value: unknown): string | null { + if (typeof value === "number" && Number.isFinite(value)) { + return new Date(value * 1000).toISOString(); + } + if (typeof value === "string") { + const date = new Date(value); + if (!Number.isNaN(date.getTime())) { + return date.toISOString(); + } + } + return null; +} + +function normalizeTokenUsage(value: unknown): CodexTokenUsage | null { + if (!isRecord(value)) return null; + const inputTokens = numberOrNull(value.input_tokens); + const cachedInputTokens = numberOrNull(value.cached_input_tokens); + const outputTokens = numberOrNull(value.output_tokens); + const reasoningOutputTokens = numberOrNull(value.reasoning_output_tokens); + const totalTokens = numberOrNull(value.total_tokens); + if ( + inputTokens == null || + cachedInputTokens == null || + outputTokens == null || + reasoningOutputTokens == null || + totalTokens == null + ) { + return null; + } + return { + input_tokens: inputTokens, + cached_input_tokens: cachedInputTokens, + output_tokens: outputTokens, + reasoning_output_tokens: reasoningOutputTokens, + total_tokens: totalTokens, + }; +} + +function normalizeRateLimitWindow( + value: unknown, +): CodexRateLimitWindow | null { + if (!isRecord(value)) return null; + const usedPercent = numberOrNull(value.used_percent); + if (usedPercent == null) return null; + return { + used_percent: usedPercent, + window_minutes: numberOrNull(value.window_minutes), + resets_at: dateStringOrNull(value.resets_at), + }; +} + +function listJsonlFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listJsonlFiles(path)); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + files.push(path); + } + } + return files; +} + +function parseCodexTelemetryLine( + line: string, + codexHome: string, +): CodexUsageTelemetry | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + const timestamp = dateStringOrNull(parsed.timestamp); + if (!timestamp) return null; + const payload = parsed.payload; + if (!isRecord(payload) || payload.type !== "token_count") return null; + + const info = isRecord(payload.info) ? payload.info : {}; + const rateLimits = isRecord(payload.rate_limits) ? payload.rate_limits : {}; + return { + captured_at: timestamp, + codex_home: codexHome, + plan_type: stringOrNull(rateLimits.plan_type), + rate_limits: { + primary: normalizeRateLimitWindow(rateLimits.primary), + secondary: normalizeRateLimitWindow(rateLimits.secondary), + rate_limit_reached_type: stringOrNull( + rateLimits.rate_limit_reached_type, + ), + }, + token_usage: { + total: normalizeTokenUsage(info.total_token_usage), + last: normalizeTokenUsage(info.last_token_usage), + model_context_window: numberOrNull(info.model_context_window), + }, + }; +} + +function getDefaultCodexHome() { + return Bun.env.CODEX_HOME ?? join(homedir(), ".codex"); +} + +function queryCodexUsage(codexHome: string): CodexUsageTelemetry | null { + const files = [ + ...listJsonlFiles(join(codexHome, "sessions")), + ...listJsonlFiles(join(codexHome, "archived_sessions")), + ]; + let latest: CodexUsageTelemetry | null = null; + for (const file of files) { + let content: string; + try { + content = readFileSync(file, "utf-8"); + } catch (err) { + logger.warn({ err, file }, "failed to read Codex session file"); + continue; + } + for (const line of content.split(/\r?\n/)) { + if (!line.trim()) continue; + const telemetry = parseCodexTelemetryLine(line, codexHome); + if ( + telemetry && + (!latest || + new Date(telemetry.captured_at).getTime() > + new Date(latest.captured_at).getTime()) + ) { + latest = telemetry; + } + } + } + return latest; +} + /** * Reads Claude Code credentials from macOS Keychain via `security find-generic-password`. * Returns the stored value (a JSON string) parsed as an object. @@ -49,43 +278,93 @@ async function queryClaudeUsage() { Authorization: `Bearer ${credentials.claudeAiOauth.accessToken}`, }), }); - return (await response.json()) as { - five_hour: { - utilization: number; - resets_at: string | null; - }; - seven_day: { - utilization: number; - resets_at: string; - }; - extra_usage: - | { - is_enabled: true; - monthly_limit: number; - used_credits: number; - utilization: number; - } - | { - is_enabled: false; - monthly_limit: null; - used_credits: null; - utilization: null; - }; - }; + return (await response.json()) as ClaudeUsage; } /** - * Usage route group. Serves Claude usage / credentials data. + * Creates the usage route group. Codex telemetry is inferred from local session + * logs and should not be treated as official billing usage. */ -export const usageRoutes = new Hono().get("/claude", async (c) => { - try { - const usage = await queryClaudeUsage(); - return c.json({ usage }); - } catch (err) { - logger.error({ err }, "failed to read Claude usage"); - return c.json( - { error: err instanceof Error ? err.message : "unknown error" }, - 500, - ); +export function createUsageRoutes(options: UsageRoutesOptions = {}) { + const getActiveAgentType = + options.getActiveAgentType ?? (() => config.agents.default.type); + const getCodexHome = options.getCodexHome ?? getDefaultCodexHome; + const readClaudeUsage = options.queryClaudeUsage ?? queryClaudeUsage; + const readCodexUsage = options.queryCodexUsage ?? queryCodexUsage; + + async function getClaudeUsageResponse() { + try { + const usage = await readClaudeUsage(); + return { usage }; + } catch (err) { + logger.error({ err }, "failed to read Claude usage"); + throw err; + } + } + + function getCodexUsageResponse() { + const codexHome = getCodexHome(); + const usage = readCodexUsage(codexHome); + if (!usage) { + return { + usage: null, + unavailable: { + codex_home: codexHome, + reason: "No local Codex usage telemetry was found.", + }, + }; + } + return { usage, unavailable: null }; } -}); + + return new Hono() + .get("/", (c) => { + return c.json({ active_agent_type: getActiveAgentType() }); + }) + .get("/current", async (c) => { + const activeAgentType = getActiveAgentType(); + if (activeAgentType === "codex") { + return c.json({ + active_agent_type: activeAgentType, + provider: "codex", + codex: getCodexUsageResponse(), + }); + } + if (activeAgentType === "claude") { + try { + return c.json({ + active_agent_type: activeAgentType, + provider: "claude", + claude: await getClaudeUsageResponse(), + }); + } catch (err) { + return c.json( + { error: err instanceof Error ? err.message : "unknown error" }, + 500, + ); + } + } + return c.json({ + active_agent_type: activeAgentType, + provider: "unavailable", + unavailable: { + reason: `Usage telemetry is not available for agent type ${activeAgentType}.`, + }, + }); + }) + .get("/claude", async (c) => { + try { + return c.json(await getClaudeUsageResponse()); + } catch (err) { + return c.json( + { error: err instanceof Error ? err.message : "unknown error" }, + 500, + ); + } + }) + .get("/codex", (c) => { + return c.json(getCodexUsageResponse()); + }); +} + +export const usageRoutes = createUsageRoutes(); diff --git a/tests/server/routes/usage.test.ts b/tests/server/routes/usage.test.ts new file mode 100644 index 0000000..e89a0ba --- /dev/null +++ b/tests/server/routes/usage.test.ts @@ -0,0 +1,232 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, test } from "bun:test"; + +import { createUsageRoutes } from "@/server/routes/usage"; + +const tempDirs: string[] = []; + +function createCodexHome() { + const codexHome = mkdtempSync(join(tmpdir(), "agentara-codex-home-")); + tempDirs.push(codexHome); + mkdirSync(join(codexHome, "sessions"), { recursive: true }); + mkdirSync(join(codexHome, "archived_sessions"), { recursive: true }); + return codexHome; +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + +describe("usageRoutes", () => { + test("returns Codex telemetry from current usage when Codex is active", async () => { + const codexHome = "/tmp/codex-home"; + const usage = { + captured_at: "2026-06-07T04:00:00.000Z", + codex_home: codexHome, + plan_type: "pro", + rate_limits: { + primary: { + used_percent: 15, + window_minutes: 300, + resets_at: "2025-10-09T08:58:20.000Z", + }, + secondary: null, + rate_limit_reached_type: null, + }, + token_usage: { + total: null, + last: null, + model_context_window: null, + }, + }; + const routes = createUsageRoutes({ + getActiveAgentType: () => "codex", + getCodexHome: () => codexHome, + queryCodexUsage: () => usage, + }); + + const response = await routes.request("/current"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + active_agent_type: "codex", + provider: "codex", + codex: { + usage, + unavailable: null, + }, + }); + }); + + test("returns latest Codex usage telemetry from local session logs", async () => { + const codexHome = createCodexHome(); + writeFileSync( + join(codexHome, "sessions", "older.jsonl"), + [ + "not-json", + JSON.stringify({ timestamp: "2026-06-07T02:00:00.000Z" }), + JSON.stringify({ + timestamp: "2026-06-07T03:00:00.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 10, + cached_input_tokens: 2, + output_tokens: 3, + reasoning_output_tokens: 1, + total_tokens: 13, + }, + last_token_usage: { + input_tokens: 10, + cached_input_tokens: 2, + output_tokens: 3, + reasoning_output_tokens: 1, + total_tokens: 13, + }, + model_context_window: 258400, + }, + rate_limits: { + limit_id: "codex", + primary: { + used_percent: 12, + window_minutes: 300, + resets_at: 1760000000, + }, + secondary: { + used_percent: 7, + window_minutes: 10080, + resets_at: 1760600000, + }, + credits: null, + plan_type: "prolite", + rate_limit_reached_type: null, + }, + }, + }), + ].join("\n"), + "utf-8", + ); + writeFileSync( + join(codexHome, "archived_sessions", "newer.jsonl"), + `${JSON.stringify({ + timestamp: "2026-06-07T04:00:00.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 20, + cached_input_tokens: 4, + output_tokens: 6, + reasoning_output_tokens: 2, + total_tokens: 26, + }, + last_token_usage: { + input_tokens: 5, + cached_input_tokens: 1, + output_tokens: 2, + reasoning_output_tokens: 1, + total_tokens: 7, + }, + model_context_window: 258400, + }, + rate_limits: { + limit_id: "codex", + primary: { + used_percent: 15, + window_minutes: 300, + resets_at: 1760000300, + }, + secondary: { + used_percent: 8, + window_minutes: 10080, + resets_at: 1760600300, + }, + credits: null, + plan_type: "pro", + rate_limit_reached_type: null, + }, + }, + })}\n`, + "utf-8", + ); + + const routes = createUsageRoutes({ + getCodexHome: () => codexHome, + }); + const response = await routes.request("/codex"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + usage: { + captured_at: "2026-06-07T04:00:00.000Z", + codex_home: codexHome, + plan_type: "pro", + rate_limits: { + primary: { + used_percent: 15, + window_minutes: 300, + resets_at: "2025-10-09T08:58:20.000Z", + }, + secondary: { + used_percent: 8, + window_minutes: 10080, + resets_at: "2025-10-16T07:38:20.000Z", + }, + rate_limit_reached_type: null, + }, + token_usage: { + total: { + input_tokens: 20, + cached_input_tokens: 4, + output_tokens: 6, + reasoning_output_tokens: 2, + total_tokens: 26, + }, + last: { + input_tokens: 5, + cached_input_tokens: 1, + output_tokens: 2, + reasoning_output_tokens: 1, + total_tokens: 7, + }, + model_context_window: 258400, + }, + }, + unavailable: null, + }); + }); + + test("returns unavailable when Codex telemetry is not present", async () => { + const codexHome = createCodexHome(); + const routes = createUsageRoutes({ + getCodexHome: () => codexHome, + }); + + const response = await routes.request("/codex"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + usage: null, + unavailable: { + codex_home: codexHome, + reason: "No local Codex usage telemetry was found.", + }, + }); + }); +}); diff --git a/web/src/app/usage/index.tsx b/web/src/app/usage/index.tsx index 8b2c983..8c094a6 100644 --- a/web/src/app/usage/index.tsx +++ b/web/src/app/usage/index.tsx @@ -9,7 +9,12 @@ import { Progress } from "@/components/ui/progress"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; -import { useClaudeUsage } from "@/lib/api"; +import { + type CodexRateLimitWindow, + type CodexTokenUsage, + type CurrentUsage, + useCurrentUsage, +} from "@/lib/api"; import { cn } from "@/lib/utils"; dayjs.extend(relativeTime); @@ -36,135 +41,392 @@ function formatResetsAt(resetsAt: string): string { return `Resets ${target.format("ddd h:mm A")}`; } +function formatWindowName(windowMinutes: number | null): string { + if (windowMinutes === 300) return "5-hour window"; + if (windowMinutes === 10080) return "7-day window"; + if (windowMinutes == null) return "Window"; + if (windowMinutes < 60) return `${windowMinutes}-minute window`; + const hours = windowMinutes / 60; + if (Number.isInteger(hours)) return `${hours}-hour window`; + return `${windowMinutes}-minute window`; +} + +const numberFormatter = new Intl.NumberFormat(); + +function RefreshControl({ + lastUpdatedLabel, + isRefetching, + onRefresh, +}: { + lastUpdatedLabel: string; + isRefetching: boolean; + onRefresh: () => void; +}) { + return ( +
+ {lastUpdatedLabel} + +
+ ); +} + +function UnavailableUsage({ + title, + reason, + codexHome, +}: { + title: string; + reason: string; + codexHome?: string; +}) { + return ( +
+

{title}

+

{reason}

+ {codexHome ? ( +

+ Codex home: {codexHome} +

+ ) : null} +
+ ); +} + +function CodexRateLimit({ + title, + rateLimit, +}: { + title: string; + rateLimit: CodexRateLimitWindow | null; +}) { + if (!rateLimit) { + return ( +
+

{title}

+

No window data available

+
+ ); + } + + return ( +
+
+

{title}

+ + {formatWindowName(rateLimit.window_minutes)} + +
+

+ {rateLimit.resets_at + ? formatResetsAt(rateLimit.resets_at) + : "Reset time unavailable"} +

+
+ + + {rateLimit.used_percent}% used + +
+
+ ); +} + +function TokenUsageRows({ + title, + usage, +}: { + title: string; + usage: CodexTokenUsage | null; +}) { + if (!usage) { + return ( +
+

{title}

+

No token data available

+
+ ); + } + + const rows = [ + ["Input", usage.input_tokens], + ["Cached input", usage.cached_input_tokens], + ["Output", usage.output_tokens], + ["Reasoning", usage.reasoning_output_tokens], + ["Total", usage.total_tokens], + ]; + + return ( +
+

{title}

+
+ {rows.map(([label, value]) => ( +
+ {label} + + {numberFormatter.format(value as number)} + +
+ ))} +
+
+ ); +} + +function ClaudeUsageContent({ + usageState, + isLoading, + lastUpdatedLabel, + isRefetching, + onRefresh, +}: { + usageState: Extract | undefined; + isLoading: boolean; + lastUpdatedLabel: string; + isRefetching: boolean; + onRefresh: () => void; +}) { + const usage = usageState?.claude.usage; + return ( + <> +
+

Current session

+ {isLoading ? ( + + ) : usage?.five_hour ? ( + <> +

+ {usage.five_hour.resets_at + ? formatResetsIn(usage.five_hour.resets_at) + : "Starts when a message is sent"} +

+
+ + + {usage.five_hour.utilization}% used + +
+ + ) : null} +
+ + + +
+

Weekly limits

+ + Learn more about usage limits + + +
+

All models

+ {isLoading ? ( + + ) : usage?.seven_day ? ( + <> +

+ {formatResetsAt(usage.seven_day.resets_at)} +

+
+ + + {usage.seven_day.utilization}% used + +
+ + ) : null} +
+ + +
+ + + +
+

Extra usage

+
+

+ Turn on extra usage to keep using Claude if you hit a limit.{" "} + + Learn more + +

+ {isLoading ? ( + + ) : ( + + )} +
+
+ + ); +} + +function CodexUsageContent({ + usageState, + lastUpdatedLabel, + isRefetching, + onRefresh, +}: { + usageState: Extract; + lastUpdatedLabel: string; + isRefetching: boolean; + onRefresh: () => void; +}) { + const usage = usageState.codex.usage; + if (!usage) { + return ( + + ); + } + + return ( + <> +
+
+

Local telemetry

+ {usage.plan_type ? ( + + {usage.plan_type} + + ) : null} +
+

+ Derived from local Codex session logs, not official billing usage. +

+
+ + + + + + + + + + + + + + + + + + + +
+

+ Captured {dayjs(usage.captured_at).fromNow()} + {usage.token_usage.model_context_window + ? ` ยท Context window ${numberFormatter.format( + usage.token_usage.model_context_window, + )}` + : ""} +

+ +
+ + ); +} + function UsagePage() { const { - data: usage, + data: currentUsage, isLoading, isRefetching, refetch, dataUpdatedAt, - } = useClaudeUsage(); + } = useCurrentUsage(); const lastUpdatedLabel = dataUpdatedAt != null ? `Last updated: ${dayjs(dataUpdatedAt).fromNow()}` : "Last updated: --"; + const title = + currentUsage?.provider === "codex" + ? "Codex rate limits" + : currentUsage?.provider === "claude" + ? "Claude usage limits" + : "Usage"; return (
- Claude usage limits + {title} - {/* Current session (five_hour) */} -
-

Current session

- {isLoading ? ( + {isLoading ? ( + <> - ) : usage?.five_hour ? ( - <> -

- {usage.five_hour.resets_at - ? formatResetsIn(usage.five_hour.resets_at) - : "Starts when a message is sent"} -

-
- - - {usage.five_hour.utilization}% used - -
- - ) : null} -
- - - - {/* Weekly limits */} -
-

Weekly limits

- - Learn more about usage limits - - - {/* All models (seven_day) */} -
-

All models

- {isLoading ? ( - - ) : usage?.seven_day ? ( - <> -

- {formatResetsAt(usage.seven_day.resets_at)} -

-
- - - {usage.seven_day.utilization}% used - -
- - ) : null} -
- -
- {lastUpdatedLabel} - -
-
- - - - {/* Extra usage */} -
-

Extra usage

-
-

- Turn on extra usage to keep using Claude if you hit a limit.{" "} - - Learn more - -

- {isLoading ? ( - - ) : ( - - )} -
-
+ + + + ) : currentUsage?.provider === "codex" ? ( + void refetch()} + /> + ) : currentUsage?.provider === "claude" ? ( + void refetch()} + /> + ) : currentUsage?.provider === "unavailable" ? ( + + ) : null}
diff --git a/web/src/lib/api/hooks.ts b/web/src/lib/api/hooks.ts index 76f3ae4..691aefd 100644 --- a/web/src/lib/api/hooks.ts +++ b/web/src/lib/api/hooks.ts @@ -279,6 +279,78 @@ export interface ClaudeUsage { }; } +/** + * Token usage values emitted by Codex token_count events. + */ +export interface CodexTokenUsage { + input_tokens: number; + cached_input_tokens: number; + output_tokens: number; + reasoning_output_tokens: number; + total_tokens: number; +} + +/** + * Codex rate-limit window telemetry derived from local session logs. + */ +export interface CodexRateLimitWindow { + used_percent: number; + window_minutes: number | null; + resets_at: string | null; +} + +/** + * Local Codex usage telemetry inferred from Codex session JSONL files. + */ +export interface CodexUsageTelemetry { + captured_at: string; + codex_home: string; + plan_type: string | null; + rate_limits: { + primary: CodexRateLimitWindow | null; + secondary: CodexRateLimitWindow | null; + rate_limit_reached_type: string | null; + }; + token_usage: { + total: CodexTokenUsage | null; + last: CodexTokenUsage | null; + model_context_window: number | null; + }; +} + +/** + * Explains why usage telemetry cannot be shown for the active configuration. + */ +export interface UsageUnavailable { + reason: string; + codex_home?: string; +} + +/** + * Usage response for whichever agent is active in the local Agentara config. + */ +export type CurrentUsage = + | { + active_agent_type: string; + provider: "claude"; + claude: { + usage: ClaudeUsage; + }; + } + | { + active_agent_type: string; + provider: "codex"; + codex: { + usage: CodexUsageTelemetry | null; + unavailable: UsageUnavailable | null; + }; + } + | { + active_agent_type: string; + provider: "unavailable"; + unavailable: UsageUnavailable; + }; + /** * Fetches Claude usage data from /api/usage/claude. */ @@ -299,3 +371,20 @@ export function useClaudeUsage() { }, }); } + +/** + * Fetches usage data for the currently configured default agent. + */ +export function useCurrentUsage() { + return useQuery({ + queryKey: ["usage", "current"], + queryFn: async () => { + const res = await api.usage.current.$get(); + const json = (await res.json()) as CurrentUsage & { error?: string }; + if (!res.ok) { + throw new Error(json.error ?? "Failed to fetch usage"); + } + return json; + }, + }); +} diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index db4f456..d95ff38 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -1,6 +1,7 @@ export { apiFetch } from "./client"; export { useClaudeUsage, + useCurrentUsage, useScheduledTaskDelete, useScheduledTasks, useScheduledTaskUpdate, @@ -19,6 +20,10 @@ export { export type { ClaudeUsage, + CodexRateLimitWindow, + CodexTokenUsage, + CodexUsageTelemetry, + CurrentUsage, ScheduledTask, ScheduledTaskUpdatePayload, } from "./hooks";