diff --git a/src/server/routes/usage.ts b/src/server/routes/usage.ts index e07ab5f..d3e7c81 100644 --- a/src/server/routes/usage.ts +++ b/src/server/routes/usage.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; -import { createLogger } from "@/shared"; +import { config, createLogger } from "@/shared"; const logger = createLogger("usage"); @@ -75,17 +75,48 @@ async function queryClaudeUsage() { } /** - * Usage route group. Serves Claude usage / credentials data. + * Options for wiring usage routes in tests or alternate runtime contexts. */ -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 interface UsageRoutesOptions { + /** Returns the currently configured default agent type. */ + getActiveAgentType?: () => string; + /** Reads Claude usage data from the underlying provider. */ + queryUsage?: typeof queryClaudeUsage; +} + +/** + * Creates the usage route group. The Claude endpoint only reads Claude + * credentials when the active agent is Claude. + */ +export function createUsageRoutes(options: UsageRoutesOptions = {}) { + const getActiveAgentType = + options.getActiveAgentType ?? (() => config.agents.default.type); + const queryUsage = options.queryUsage ?? queryClaudeUsage; + + return new Hono().get("/claude", async (c) => { + const activeAgentType = getActiveAgentType(); + if (activeAgentType !== "claude") { + return c.json({ + usage: null, + unavailable: { + active_agent_type: activeAgentType, + reason: + "Claude usage is only available when the active agent is Claude.", + }, + }); + } + + try { + const usage = await queryUsage(); + 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 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..aa18bf9 --- /dev/null +++ b/tests/server/routes/usage.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; + +import { createUsageRoutes } from "@/server/routes/usage"; + +describe("usageRoutes", () => { + test("queries Claude usage when the active agent is Claude", async () => { + let queried = false; + const usage = { + five_hour: { + utilization: 0.25, + resets_at: null, + }, + seven_day: { + utilization: 0.5, + resets_at: "2026-06-07T12:00:00Z", + }, + extra_usage: { + is_enabled: false as const, + monthly_limit: null, + used_credits: null, + utilization: null, + }, + }; + const routes = createUsageRoutes({ + getActiveAgentType: () => "claude", + queryUsage: async () => { + queried = true; + return usage; + }, + }); + + const response = await routes.request("/claude"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(queried).toBe(true); + expect(body).toEqual({ usage }); + }); + + test("does not query Claude usage when the active agent is not Claude", async () => { + let queried = false; + const routes = createUsageRoutes({ + getActiveAgentType: () => "codex", + queryUsage: async () => { + queried = true; + throw new Error("should not query Claude usage"); + }, + }); + + const response = await routes.request("/claude"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(queried).toBe(false); + expect(body).toEqual({ + usage: null, + unavailable: { + active_agent_type: "codex", + reason: "Claude usage is only available when the active agent is Claude.", + }, + }); + }); +}); diff --git a/web/src/app/usage/index.tsx b/web/src/app/usage/index.tsx index 8b2c983..3fcdf80 100644 --- a/web/src/app/usage/index.tsx +++ b/web/src/app/usage/index.tsx @@ -38,12 +38,14 @@ function formatResetsAt(resetsAt: string): string { function UsagePage() { const { - data: usage, + data: usageStatus, isLoading, isRefetching, refetch, dataUpdatedAt, } = useClaudeUsage(); + const usage = usageStatus?.usage ?? null; + const unavailable = usageStatus?.unavailable ?? null; const lastUpdatedLabel = dataUpdatedAt != null @@ -58,6 +60,16 @@ function UsagePage() { Claude usage limits + {unavailable ? ( +

+ Claude usage is unavailable because the active agent is{" "} + + {unavailable.active_agent_type} + + . +

+ ) : null} + {/* Current session (five_hour) */}

Current session

diff --git a/web/src/lib/api/hooks.ts b/web/src/lib/api/hooks.ts index 76f3ae4..73e750f 100644 --- a/web/src/lib/api/hooks.ts +++ b/web/src/lib/api/hooks.ts @@ -262,6 +262,9 @@ export function useSoulMemoryUpdate() { // --- Usage --- +/** + * Claude usage data returned by the usage endpoint. + */ export interface ClaudeUsage { five_hour: { utilization: number; @@ -279,6 +282,14 @@ export interface ClaudeUsage { }; } +/** + * Explains why Claude usage data cannot be shown for the active configuration. + */ +export interface ClaudeUsageUnavailable { + active_agent_type: string; + reason: string; +} + /** * Fetches Claude usage data from /api/usage/claude. */ @@ -288,14 +299,24 @@ export function useClaudeUsage() { queryFn: async () => { const res = await api.usage.claude.$get(); const json = (await res.json()) as { - usage?: ClaudeUsage; + usage?: ClaudeUsage | null; + unavailable?: ClaudeUsageUnavailable; error?: string; }; if (!res.ok) { throw new Error(json.error ?? "Failed to fetch usage"); } + if (json.unavailable) { + return { + usage: null, + unavailable: json.unavailable, + }; + } if (!json.usage) throw new Error("Invalid usage response"); - return json.usage; + return { + usage: json.usage, + unavailable: null, + }; }, }); }