From badafdc7b3e43e838c198dea60d3a718d2d8dab3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 12:50:55 +0000 Subject: [PATCH] refactor: dedupe provider usage fetch logic and tests --- .../provider-usage.fetch.antigravity.test.ts | 294 ++++++++---------- src/infra/provider-usage.fetch.claude.test.ts | 177 +++++++++++ src/infra/provider-usage.fetch.claude.ts | 12 +- src/infra/provider-usage.fetch.codex.test.ts | 57 ++++ src/infra/provider-usage.fetch.codex.ts | 20 +- .../provider-usage.fetch.copilot.test.ts | 37 +++ src/infra/provider-usage.fetch.copilot.ts | 10 +- src/infra/provider-usage.fetch.gemini.test.ts | 39 +++ src/infra/provider-usage.fetch.gemini.ts | 10 +- .../provider-usage.fetch.minimax.test.ts | 151 +++++++++ src/infra/provider-usage.fetch.minimax.ts | 20 +- src/infra/provider-usage.fetch.shared.test.ts | 38 +++ src/infra/provider-usage.fetch.shared.ts | 33 ++ src/infra/provider-usage.fetch.zai.test.ts | 86 +++++ src/infra/provider-usage.fetch.zai.ts | 10 +- src/test-utils/provider-usage-fetch.ts | 27 ++ 16 files changed, 806 insertions(+), 215 deletions(-) create mode 100644 src/infra/provider-usage.fetch.claude.test.ts create mode 100644 src/infra/provider-usage.fetch.codex.test.ts create mode 100644 src/infra/provider-usage.fetch.copilot.test.ts create mode 100644 src/infra/provider-usage.fetch.gemini.test.ts create mode 100644 src/infra/provider-usage.fetch.minimax.test.ts create mode 100644 src/infra/provider-usage.fetch.shared.test.ts create mode 100644 src/infra/provider-usage.fetch.zai.test.ts create mode 100644 src/test-utils/provider-usage-fetch.ts diff --git a/src/infra/provider-usage.fetch.antigravity.test.ts b/src/infra/provider-usage.fetch.antigravity.test.ts index c85c28c6c8..1784481ce5 100644 --- a/src/infra/provider-usage.fetch.antigravity.test.ts +++ b/src/infra/provider-usage.fetch.antigravity.test.ts @@ -1,25 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { describe, expect, it } from "vitest"; +import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; import { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js"; -const makeResponse = (status: number, body: unknown): Response => { - const payload = typeof body === "string" ? body : JSON.stringify(body); - const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" }; - return new Response(payload, { status, headers }); -}; - -const toRequestUrl = (input: Parameters[0]): string => - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - -const createAntigravityFetch = ( - handler: (url: string, init?: Parameters[1]) => Promise | Response, -) => { - const mockFetch = vi.fn(async (input: string | Request | URL, init?: RequestInit) => - handler(toRequestUrl(input), init), - ); - return withFetchPreconnect(mockFetch) as typeof fetch & typeof mockFetch; -}; - const getRequestBody = (init?: Parameters[1]) => typeof init?.body === "string" ? init.body : undefined; @@ -29,7 +11,7 @@ function createEndpointFetch(spec: { loadCodeAssist?: EndpointHandler; fetchAvailableModels?: EndpointHandler; }) { - return createAntigravityFetch(async (url, init) => { + return createProviderUsageFetch(async (url, init) => { if (url.includes("loadCodeAssist")) { return (await spec.loadCodeAssist?.(init)) ?? makeResponse(404, "not found"); } @@ -40,7 +22,7 @@ function createEndpointFetch(spec: { }); } -async function runUsage(mockFetch: ReturnType) { +async function runUsage(mockFetch: ReturnType) { return fetchAntigravityUsage("token-123", 5000, mockFetch as unknown as typeof fetch); } @@ -48,6 +30,20 @@ function findWindow(snapshot: Awaited>, return snapshot.windows.find((window) => window.label === label); } +function expectTokenExpired(snapshot: Awaited>) { + expect(snapshot.error).toBe("Token expired"); + expect(snapshot.windows).toHaveLength(0); +} + +function expectSingleWindow( + snapshot: Awaited>, + label: string, +) { + expect(snapshot.windows).toHaveLength(1); + expect(snapshot.windows[0]?.label).toBe(label); + return snapshot.windows[0]; +} + describe("fetchAntigravityUsage", () => { it("returns 3 windows when both endpoints succeed", async () => { const mockFetch = createEndpointFetch({ @@ -209,9 +205,7 @@ describe("fetchAntigravityUsage", () => { }); const snapshot = await runUsage(mockFetch); - - expect(snapshot.error).toBe("Token expired"); - expect(snapshot.windows).toHaveLength(0); + expectTokenExpired(snapshot); }); it.each([ @@ -246,54 +240,41 @@ describe("fetchAntigravityUsage", () => { it("includes reset times in model windows", async () => { const resetTime = "2026-01-10T12:00:00Z"; - const mockFetch = createAntigravityFetch(async (url) => { - if (url.includes("loadCodeAssist")) { - return makeResponse(500, "Error"); - } - - if (url.includes("fetchAvailableModels")) { - return makeResponse(200, { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => makeResponse(500, "Error"), + fetchAvailableModels: () => + makeResponse(200, { models: { "gemini-pro-experimental": { quotaInfo: { remainingFraction: 0.3, resetTime }, }, }, - }); - } - - return makeResponse(404, "not found"); + }), }); - const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); - + const snapshot = await runUsage(mockFetch); const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-experimental"); expect(proWindow?.resetAt).toBe(new Date(resetTime).getTime()); }); it("parses string numbers correctly", async () => { - const mockFetch = createAntigravityFetch(async (url) => { - if (url.includes("loadCodeAssist")) { - return makeResponse(200, { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => + makeResponse(200, { availablePromptCredits: "600", planInfo: { monthlyPromptCredits: "1000" }, - }); - } - - if (url.includes("fetchAvailableModels")) { - return makeResponse(200, { + }), + fetchAvailableModels: () => + makeResponse(200, { models: { "gemini-flash-lite": { quotaInfo: { remainingFraction: "0.9" }, }, }, - }); - } - - return makeResponse(404, "not found"); + }), }); - const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); - + const snapshot = await runUsage(mockFetch); expect(snapshot.windows).toHaveLength(2); const creditsWindow = snapshot.windows.find((w) => w.label === "Credits"); @@ -304,65 +285,43 @@ describe("fetchAntigravityUsage", () => { }); it("skips internal models", async () => { - const mockFetch = createAntigravityFetch(async (url) => { - if (url.includes("loadCodeAssist")) { - return makeResponse(200, { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => + makeResponse(200, { availablePromptCredits: 500, planInfo: { monthlyPromptCredits: 1000 }, cloudaicompanionProject: "projects/internal", - }); - } - - if (url.includes("fetchAvailableModels")) { - return makeResponse(200, { + }), + fetchAvailableModels: () => + makeResponse(200, { models: { chat_hidden: { quotaInfo: { remainingFraction: 0.1 } }, tab_hidden: { quotaInfo: { remainingFraction: 0.2 } }, "gemini-pro-1.5": { quotaInfo: { remainingFraction: 0.7 } }, }, - }); - } - - return makeResponse(404, "not found"); + }), }); - const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); - + const snapshot = await runUsage(mockFetch); expect(snapshot.windows.map((w) => w.label)).toEqual(["Credits", "gemini-pro-1.5"]); }); it("sorts models by usage and shows individual model IDs", async () => { - const mockFetch = createAntigravityFetch(async (url) => { - if (url.includes("loadCodeAssist")) { - return makeResponse(500, "Error"); - } - - if (url.includes("fetchAvailableModels")) { - return makeResponse(200, { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => makeResponse(500, "Error"), + fetchAvailableModels: () => + makeResponse(200, { models: { - "gemini-pro-1.0": { - quotaInfo: { remainingFraction: 0.8 }, - }, - "gemini-pro-1.5": { - quotaInfo: { remainingFraction: 0.3 }, - }, - "gemini-flash-1.5": { - quotaInfo: { remainingFraction: 0.6 }, - }, - "gemini-flash-2.0": { - quotaInfo: { remainingFraction: 0.9 }, - }, + "gemini-pro-1.0": { quotaInfo: { remainingFraction: 0.8 } }, + "gemini-pro-1.5": { quotaInfo: { remainingFraction: 0.3 } }, + "gemini-flash-1.5": { quotaInfo: { remainingFraction: 0.6 } }, + "gemini-flash-2.0": { quotaInfo: { remainingFraction: 0.9 } }, }, - }); - } - - return makeResponse(404, "not found"); + }), }); - const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); - + const snapshot = await runUsage(mockFetch); expect(snapshot.windows).toHaveLength(4); - // Should be sorted by usage (highest first) expect(snapshot.windows[0]?.label).toBe("gemini-pro-1.5"); expect(snapshot.windows[0]?.usedPercent).toBe(70); // (1 - 0.3) * 100 expect(snapshot.windows[1]?.label).toBe("gemini-flash-1.5"); @@ -374,124 +333,137 @@ describe("fetchAntigravityUsage", () => { }); it("returns Token expired error on 401 from loadCodeAssist", async () => { - const mockFetch = createAntigravityFetch(async (url) => { - if (url.includes("loadCodeAssist")) { - return makeResponse(401, { error: { message: "Unauthorized" } }); - } - - return makeResponse(404, "not found"); + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => makeResponse(401, { error: { message: "Unauthorized" } }), }); - const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); - - expect(snapshot.error).toBe("Token expired"); - expect(snapshot.windows).toHaveLength(0); + const snapshot = await runUsage(mockFetch); + expectTokenExpired(snapshot); expect(mockFetch).toHaveBeenCalledTimes(1); // Should stop early on 401 }); - it("handles empty models array gracefully", async () => { - const mockFetch = createAntigravityFetch(async (url) => { - if (url.includes("loadCodeAssist")) { - return makeResponse(200, { + it("handles empty models object gracefully", async () => { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => + makeResponse(200, { availablePromptCredits: 800, planInfo: { monthlyPromptCredits: 1000 }, - }); - } - - if (url.includes("fetchAvailableModels")) { - return makeResponse(200, { models: {} }); - } - - return makeResponse(404, "not found"); + }), + fetchAvailableModels: () => makeResponse(200, { models: {} }), }); - const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); - + const snapshot = await runUsage(mockFetch); expect(snapshot.windows).toHaveLength(1); const creditsWindow = snapshot.windows[0]; expect(creditsWindow?.label).toBe("Credits"); expect(creditsWindow?.usedPercent).toBe(20); }); - it("handles missing credits fields gracefully", async () => { - const mockFetch = createAntigravityFetch(async (url) => { - if (url.includes("loadCodeAssist")) { - return makeResponse(200, { planType: "Free" }); - } + it("handles missing or invalid model quota payloads", async () => { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => makeResponse(500, "Error"), + fetchAvailableModels: () => + makeResponse(200, { + models: { + no_quota: {}, + missing_fraction: { quotaInfo: {} }, + invalid_fraction: { quotaInfo: { remainingFraction: "oops" } }, + valid_model: { quotaInfo: { remainingFraction: 0.25 } }, + }, + }), + }); - if (url.includes("fetchAvailableModels")) { - return makeResponse(200, { + const snapshot = await runUsage(mockFetch); + expect(snapshot.windows).toEqual([{ label: "valid_model", usedPercent: 75 }]); + }); + + it("handles non-object models payload gracefully", async () => { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => + makeResponse(200, { + availablePromptCredits: 900, + planInfo: { monthlyPromptCredits: 1000 }, + }), + fetchAvailableModels: () => makeResponse(200, { models: null }), + }); + + const snapshot = await runUsage(mockFetch); + expect(snapshot.windows).toEqual([{ label: "Credits", usedPercent: 10 }]); + }); + + it("handles missing credits fields gracefully", async () => { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => makeResponse(200, { planType: "Free" }), + fetchAvailableModels: () => + makeResponse(200, { models: { "gemini-flash-experimental": { quotaInfo: { remainingFraction: 0.5 }, }, }, - }); - } - - return makeResponse(404, "not found"); + }), }); - const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); - - expect(snapshot.windows).toHaveLength(1); - const flashWindow = snapshot.windows[0]; - expect(flashWindow?.label).toBe("gemini-flash-experimental"); + const snapshot = await runUsage(mockFetch); + const flashWindow = expectSingleWindow(snapshot, "gemini-flash-experimental"); expect(flashWindow?.usedPercent).toBe(50); expect(snapshot.plan).toBe("Free"); }); it("handles invalid reset time gracefully", async () => { - const mockFetch = createAntigravityFetch(async (url) => { - if (url.includes("loadCodeAssist")) { - return makeResponse(500, "Error"); - } - - if (url.includes("fetchAvailableModels")) { - return makeResponse(200, { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => makeResponse(500, "Error"), + fetchAvailableModels: () => + makeResponse(200, { models: { "gemini-pro-test": { quotaInfo: { remainingFraction: 0.4, resetTime: "invalid-date" }, }, }, - }); - } - - return makeResponse(404, "not found"); + }), }); - const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); - + const snapshot = await runUsage(mockFetch); const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-test"); expect(proWindow?.usedPercent).toBe(60); expect(proWindow?.resetAt).toBeUndefined(); }); - it("handles network errors with graceful degradation", async () => { - const mockFetch = createAntigravityFetch(async (url) => { - if (url.includes("loadCodeAssist")) { + it("handles loadCodeAssist network errors with graceful degradation", async () => { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => { throw new Error("Network failure"); - } - - if (url.includes("fetchAvailableModels")) { - return makeResponse(200, { + }, + fetchAvailableModels: () => + makeResponse(200, { models: { "gemini-flash-stable": { quotaInfo: { remainingFraction: 0.85 }, }, }, - }); - } - - return makeResponse(404, "not found"); + }), }); - const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); - - expect(snapshot.windows).toHaveLength(1); - const flashWindow = snapshot.windows[0]; - expect(flashWindow?.label).toBe("gemini-flash-stable"); + const snapshot = await runUsage(mockFetch); + const flashWindow = expectSingleWindow(snapshot, "gemini-flash-stable"); expect(flashWindow?.usedPercent).toBeCloseTo(15, 1); expect(snapshot.error).toBeUndefined(); }); + + it("handles fetchAvailableModels network errors with graceful degradation", async () => { + const mockFetch = createEndpointFetch({ + loadCodeAssist: () => + makeResponse(200, { + availablePromptCredits: 300, + planInfo: { monthlyPromptCredits: 1000 }, + }), + fetchAvailableModels: () => { + throw new Error("Network failure"); + }, + }); + + const snapshot = await runUsage(mockFetch); + expect(snapshot.windows).toEqual([{ label: "Credits", usedPercent: 70 }]); + expect(snapshot.error).toBeUndefined(); + }); }); diff --git a/src/infra/provider-usage.fetch.claude.test.ts b/src/infra/provider-usage.fetch.claude.test.ts new file mode 100644 index 0000000000..b8fbaffb71 --- /dev/null +++ b/src/infra/provider-usage.fetch.claude.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { fetchClaudeUsage } from "./provider-usage.fetch.claude.js"; + +const MISSING_SCOPE_MESSAGE = "missing scope requirement user:profile"; + +function makeMissingScopeResponse() { + return makeResponse(403, { + error: { message: MISSING_SCOPE_MESSAGE }, + }); +} + +function expectMissingScopeError(result: Awaited>) { + expect(result.error).toBe(`HTTP 403: ${MISSING_SCOPE_MESSAGE}`); + expect(result.windows).toHaveLength(0); +} + +function createScopeFallbackFetch(handler: (url: string) => Promise | Response) { + return createProviderUsageFetch(async (url) => { + if (url.includes("/api/oauth/usage")) { + return makeMissingScopeResponse(); + } + return handler(url); + }); +} + +type ScopeFallbackFetch = ReturnType; + +async function expectMissingScopeWithoutFallback(mockFetch: ScopeFallbackFetch) { + const result = await fetchClaudeUsage("token", 5000, mockFetch); + expectMissingScopeError(result); + expect(mockFetch).toHaveBeenCalledTimes(1); +} + +function makeOrgAResponse() { + return makeResponse(200, [{ uuid: "org-a" }]); +} + +describe("fetchClaudeUsage", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("parses oauth usage windows", async () => { + const fiveHourReset = "2026-01-08T00:00:00Z"; + const weekReset = "2026-01-12T00:00:00Z"; + const mockFetch = createProviderUsageFetch(async (_url, init) => { + const headers = (init?.headers as Record | undefined) ?? {}; + expect(headers.Authorization).toBe("Bearer token"); + expect(headers["anthropic-beta"]).toBe("oauth-2025-04-20"); + + return makeResponse(200, { + five_hour: { utilization: 18, resets_at: fiveHourReset }, + seven_day: { utilization: 54, resets_at: weekReset }, + seven_day_sonnet: { utilization: 67 }, + }); + }); + + const result = await fetchClaudeUsage("token", 5000, mockFetch); + + expect(result.windows).toEqual([ + { label: "5h", usedPercent: 18, resetAt: new Date(fiveHourReset).getTime() }, + { label: "Week", usedPercent: 54, resetAt: new Date(weekReset).getTime() }, + { label: "Sonnet", usedPercent: 67 }, + ]); + }); + + it("returns HTTP errors with provider message suffix", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(403, { + error: { message: "scope not granted" }, + }), + ); + + const result = await fetchClaudeUsage("token", 5000, mockFetch); + expect(result.error).toBe("HTTP 403: scope not granted"); + expect(result.windows).toHaveLength(0); + }); + + it("falls back to claude web usage when oauth scope is missing", async () => { + vi.stubEnv("CLAUDE_AI_SESSION_KEY", "sk-ant-session-key"); + + const mockFetch = createProviderUsageFetch(async (url, init) => { + if (url.includes("/api/oauth/usage")) { + return makeMissingScopeResponse(); + } + + const headers = (init?.headers as Record | undefined) ?? {}; + expect(headers.Cookie).toBe("sessionKey=sk-ant-session-key"); + + if (url.endsWith("/api/organizations")) { + return makeResponse(200, [{ uuid: "org-123" }]); + } + + if (url.endsWith("/api/organizations/org-123/usage")) { + return makeResponse(200, { + five_hour: { utilization: 12 }, + }); + } + + return makeResponse(404, "not found"); + }); + + const result = await fetchClaudeUsage("token", 5000, mockFetch); + + expect(result.error).toBeUndefined(); + expect(result.windows).toEqual([{ label: "5h", usedPercent: 12, resetAt: undefined }]); + }); + + it("keeps oauth error when cookie header cannot be parsed into a session key", async () => { + vi.stubEnv("CLAUDE_WEB_COOKIE", "sessionKey=sk-ant-cookie-session"); + + const mockFetch = createScopeFallbackFetch(async (url) => { + if (url.endsWith("/api/organizations")) { + return makeResponse(200, [{ uuid: "org-cookie" }]); + } + if (url.endsWith("/api/organizations/org-cookie/usage")) { + return makeResponse(200, { seven_day_opus: { utilization: 44 } }); + } + return makeResponse(404, "not found"); + }); + + await expectMissingScopeWithoutFallback(mockFetch); + }); + + it("keeps oauth error when fallback session key is unavailable", async () => { + const mockFetch = createScopeFallbackFetch(async (url) => { + if (url.endsWith("/api/organizations")) { + return makeResponse(200, [{ uuid: "org-missing-session" }]); + } + return makeResponse(404, "not found"); + }); + + await expectMissingScopeWithoutFallback(mockFetch); + }); + + it.each([ + { + name: "org list request fails", + orgResponse: () => makeResponse(500, "boom"), + usageResponse: () => makeResponse(200, {}), + }, + { + name: "org list has no id", + orgResponse: () => makeResponse(200, [{}]), + usageResponse: () => makeResponse(200, {}), + }, + { + name: "usage request fails", + orgResponse: makeOrgAResponse, + usageResponse: () => makeResponse(503, "down"), + }, + { + name: "usage request has no windows", + orgResponse: makeOrgAResponse, + usageResponse: () => makeResponse(200, {}), + }, + ])( + "returns oauth error when web fallback is unavailable: $name", + async ({ orgResponse, usageResponse }) => { + vi.stubEnv("CLAUDE_AI_SESSION_KEY", "sk-ant-fallback"); + + const mockFetch = createScopeFallbackFetch(async (url) => { + if (url.endsWith("/api/organizations")) { + return orgResponse(); + } + if (url.endsWith("/api/organizations/org-a/usage")) { + return usageResponse(); + } + return makeResponse(404, "not found"); + }); + + const result = await fetchClaudeUsage("token", 5000, mockFetch); + expectMissingScopeError(result); + }, + ); +}); diff --git a/src/infra/provider-usage.fetch.claude.ts b/src/infra/provider-usage.fetch.claude.ts index 7a91448e23..927c76e4c0 100644 --- a/src/infra/provider-usage.fetch.claude.ts +++ b/src/infra/provider-usage.fetch.claude.ts @@ -1,4 +1,4 @@ -import { fetchJson } from "./provider-usage.fetch.shared.js"; +import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; @@ -159,13 +159,11 @@ export async function fetchClaudeUsage( } } - const suffix = message ? `: ${message}` : ""; - return { + return buildUsageHttpErrorSnapshot({ provider: "anthropic", - displayName: PROVIDER_LABELS.anthropic, - windows: [], - error: `HTTP ${res.status}${suffix}`, - }; + status: res.status, + message, + }); } const data = (await res.json()) as ClaudeUsageResponse; diff --git a/src/infra/provider-usage.fetch.codex.test.ts b/src/infra/provider-usage.fetch.codex.test.ts new file mode 100644 index 0000000000..cbbfbd4dba --- /dev/null +++ b/src/infra/provider-usage.fetch.codex.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { fetchCodexUsage } from "./provider-usage.fetch.codex.js"; + +describe("fetchCodexUsage", () => { + it("returns token expired for auth failures", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(401, { error: "unauthorized" }), + ); + + const result = await fetchCodexUsage("token", undefined, 5000, mockFetch); + expect(result.error).toBe("Token expired"); + expect(result.windows).toHaveLength(0); + }); + + it("returns HTTP status errors for non-auth failures", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(429, { error: "throttled" }), + ); + + const result = await fetchCodexUsage("token", undefined, 5000, mockFetch); + expect(result.error).toBe("HTTP 429"); + expect(result.windows).toHaveLength(0); + }); + + it("parses windows, reset times, and plan balance", async () => { + const mockFetch = createProviderUsageFetch(async (_url, init) => { + const headers = (init?.headers as Record | undefined) ?? {}; + expect(headers["ChatGPT-Account-Id"]).toBe("acct-1"); + return makeResponse(200, { + rate_limit: { + primary_window: { + limit_window_seconds: 10_800, + used_percent: 35.5, + reset_at: 1_700_000_000, + }, + secondary_window: { + limit_window_seconds: 86_400, + used_percent: 75, + reset_at: 1_700_050_000, + }, + }, + plan_type: "Plus", + credits: { balance: "12.5" }, + }); + }); + + const result = await fetchCodexUsage("token", "acct-1", 5000, mockFetch); + + expect(result.provider).toBe("openai-codex"); + expect(result.plan).toBe("Plus ($12.50)"); + expect(result.windows).toEqual([ + { label: "3h", usedPercent: 35.5, resetAt: 1_700_000_000_000 }, + { label: "Day", usedPercent: 75, resetAt: 1_700_050_000_000 }, + ]); + }); +}); diff --git a/src/infra/provider-usage.fetch.codex.ts b/src/infra/provider-usage.fetch.codex.ts index 6078c95e13..4d4cfc7fdd 100644 --- a/src/infra/provider-usage.fetch.codex.ts +++ b/src/infra/provider-usage.fetch.codex.ts @@ -1,4 +1,4 @@ -import { fetchJson } from "./provider-usage.fetch.shared.js"; +import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; @@ -41,22 +41,12 @@ export async function fetchCodexUsage( fetchFn, ); - if (res.status === 401 || res.status === 403) { - return { - provider: "openai-codex", - displayName: PROVIDER_LABELS["openai-codex"], - windows: [], - error: "Token expired", - }; - } - if (!res.ok) { - return { + return buildUsageHttpErrorSnapshot({ provider: "openai-codex", - displayName: PROVIDER_LABELS["openai-codex"], - windows: [], - error: `HTTP ${res.status}`, - }; + status: res.status, + tokenExpiredStatuses: [401, 403], + }); } const data = (await res.json()) as CodexUsageResponse; diff --git a/src/infra/provider-usage.fetch.copilot.test.ts b/src/infra/provider-usage.fetch.copilot.test.ts new file mode 100644 index 0000000000..7df1711815 --- /dev/null +++ b/src/infra/provider-usage.fetch.copilot.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js"; + +describe("fetchCopilotUsage", () => { + it("returns HTTP errors for failed requests", async () => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom")); + const result = await fetchCopilotUsage("token", 5000, mockFetch); + + expect(result.error).toBe("HTTP 500"); + expect(result.windows).toHaveLength(0); + }); + + it("parses premium/chat usage from remaining percentages", async () => { + const mockFetch = createProviderUsageFetch(async (_url, init) => { + const headers = (init?.headers as Record | undefined) ?? {}; + expect(headers.Authorization).toBe("token token"); + expect(headers["X-Github-Api-Version"]).toBe("2025-04-01"); + + return makeResponse(200, { + quota_snapshots: { + premium_interactions: { percent_remaining: 20 }, + chat: { percent_remaining: 75 }, + }, + copilot_plan: "pro", + }); + }); + + const result = await fetchCopilotUsage("token", 5000, mockFetch); + + expect(result.plan).toBe("pro"); + expect(result.windows).toEqual([ + { label: "Premium", usedPercent: 80 }, + { label: "Chat", usedPercent: 25 }, + ]); + }); +}); diff --git a/src/infra/provider-usage.fetch.copilot.ts b/src/infra/provider-usage.fetch.copilot.ts index 3782982aa2..40d4adcd3a 100644 --- a/src/infra/provider-usage.fetch.copilot.ts +++ b/src/infra/provider-usage.fetch.copilot.ts @@ -1,4 +1,4 @@ -import { fetchJson } from "./provider-usage.fetch.shared.js"; +import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; @@ -30,12 +30,10 @@ export async function fetchCopilotUsage( ); if (!res.ok) { - return { + return buildUsageHttpErrorSnapshot({ provider: "github-copilot", - displayName: PROVIDER_LABELS["github-copilot"], - windows: [], - error: `HTTP ${res.status}`, - }; + status: res.status, + }); } const data = (await res.json()) as CopilotUsageResponse; diff --git a/src/infra/provider-usage.fetch.gemini.test.ts b/src/infra/provider-usage.fetch.gemini.test.ts new file mode 100644 index 0000000000..ea71347801 --- /dev/null +++ b/src/infra/provider-usage.fetch.gemini.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js"; + +describe("fetchGeminiUsage", () => { + it("returns HTTP errors for failed requests", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(429, { error: "rate_limited" }), + ); + const result = await fetchGeminiUsage("token", 5000, mockFetch, "google-gemini-cli"); + + expect(result.error).toBe("HTTP 429"); + expect(result.windows).toHaveLength(0); + }); + + it("selects the lowest remaining fraction per model family", async () => { + const mockFetch = createProviderUsageFetch(async (_url, init) => { + const headers = (init?.headers as Record | undefined) ?? {}; + expect(headers.Authorization).toBe("Bearer token"); + + return makeResponse(200, { + buckets: [ + { modelId: "gemini-pro", remainingFraction: 0.8 }, + { modelId: "gemini-pro-preview", remainingFraction: 0.3 }, + { modelId: "gemini-flash", remainingFraction: 0.7 }, + { modelId: "gemini-flash-latest", remainingFraction: 0.9 }, + { modelId: "gemini-unknown", remainingFraction: 0.5 }, + ], + }); + }); + + const result = await fetchGeminiUsage("token", 5000, mockFetch, "google-gemini-cli"); + + expect(result.windows).toHaveLength(2); + expect(result.windows[0]).toEqual({ label: "Pro", usedPercent: 70 }); + expect(result.windows[1]?.label).toBe("Flash"); + expect(result.windows[1]?.usedPercent).toBeCloseTo(30, 6); + }); +}); diff --git a/src/infra/provider-usage.fetch.gemini.ts b/src/infra/provider-usage.fetch.gemini.ts index 39a5806417..e9d43aa571 100644 --- a/src/infra/provider-usage.fetch.gemini.ts +++ b/src/infra/provider-usage.fetch.gemini.ts @@ -1,4 +1,4 @@ -import { fetchJson } from "./provider-usage.fetch.shared.js"; +import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, @@ -31,12 +31,10 @@ export async function fetchGeminiUsage( ); if (!res.ok) { - return { + return buildUsageHttpErrorSnapshot({ provider, - displayName: PROVIDER_LABELS[provider], - windows: [], - error: `HTTP ${res.status}`, - }; + status: res.status, + }); } const data = (await res.json()) as GeminiUsageResponse; diff --git a/src/infra/provider-usage.fetch.minimax.test.ts b/src/infra/provider-usage.fetch.minimax.test.ts new file mode 100644 index 0000000000..1c13619b8d --- /dev/null +++ b/src/infra/provider-usage.fetch.minimax.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { fetchMinimaxUsage } from "./provider-usage.fetch.minimax.js"; + +describe("fetchMinimaxUsage", () => { + it("returns HTTP errors for failed requests", async () => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(502, "bad gateway")); + const result = await fetchMinimaxUsage("key", 5000, mockFetch); + + expect(result.error).toBe("HTTP 502"); + expect(result.windows).toHaveLength(0); + }); + + it("returns invalid JSON when payload cannot be parsed", async () => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(200, "{not-json")); + const result = await fetchMinimaxUsage("key", 5000, mockFetch); + + expect(result.error).toBe("Invalid JSON"); + expect(result.windows).toHaveLength(0); + }); + + it("returns API errors from base_resp", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + base_resp: { + status_code: 1007, + status_msg: " auth denied ", + }, + }), + ); + const result = await fetchMinimaxUsage("key", 5000, mockFetch); + + expect(result.error).toBe("auth denied"); + expect(result.windows).toHaveLength(0); + }); + + it("derives usage from used/total fields and includes reset + plan", async () => { + const mockFetch = createProviderUsageFetch(async (_url, init) => { + const headers = (init?.headers as Record | undefined) ?? {}; + expect(headers.Authorization).toBe("Bearer key"); + expect(headers["MM-API-Source"]).toBe("OpenClaw"); + + return makeResponse(200, { + data: { + used: 35, + total: 100, + window_hours: 3, + reset_at: 1_700_000_000, + plan_name: "Pro Max", + }, + }); + }); + + const result = await fetchMinimaxUsage("key", 5000, mockFetch); + + expect(result.plan).toBe("Pro Max"); + expect(result.windows).toEqual([ + { + label: "3h", + usedPercent: 35, + resetAt: 1_700_000_000_000, + }, + ]); + }); + + it("supports usage ratio strings with minute windows and ISO reset strings", async () => { + const resetIso = "2026-01-08T00:00:00Z"; + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + data: { + nested: [ + { + usage_ratio: "0.25", + window_minutes: "30", + reset_time: resetIso, + plan: "Starter", + }, + ], + }, + }), + ); + + const result = await fetchMinimaxUsage("key", 5000, mockFetch); + expect(result.plan).toBe("Starter"); + expect(result.windows).toEqual([ + { + label: "30m", + usedPercent: 25, + resetAt: new Date(resetIso).getTime(), + }, + ]); + }); + + it("derives used from total and remaining counts", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + data: { + total: "200", + remaining: "50", + usage_percent: 75, + reset_at: 1_700_000_000_000, + plan_name: "Team", + }, + }), + ); + + const result = await fetchMinimaxUsage("key", 5000, mockFetch); + expect(result.plan).toBe("Team"); + expect(result.windows).toEqual([ + { + label: "5h", + usedPercent: 75, + resetAt: 1_700_000_000_000, + }, + ]); + }); + + it("returns unsupported response shape when no usage fields are present", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { data: { foo: "bar" } }), + ); + const result = await fetchMinimaxUsage("key", 5000, mockFetch); + + expect(result.error).toBe("Unsupported response shape"); + expect(result.windows).toHaveLength(0); + }); + + it("handles repeated nested records while scanning usage candidates", async () => { + const sharedUsage = { + total: 100, + used: 20, + usage_percent: 90, + window_hours: 1, + }; + const dataWithSharedReference = { + first: sharedUsage, + nested: [sharedUsage], + }; + const mockFetch = createProviderUsageFetch( + async () => + ({ + ok: true, + status: 200, + json: async () => ({ data: dataWithSharedReference }), + }) as Response, + ); + + const result = await fetchMinimaxUsage("key", 5000, mockFetch); + expect(result.windows).toEqual([{ label: "1h", usedPercent: 20, resetAt: undefined }]); + }); +}); diff --git a/src/infra/provider-usage.fetch.minimax.ts b/src/infra/provider-usage.fetch.minimax.ts index 7ffe7a3f1d..ee794a90a1 100644 --- a/src/infra/provider-usage.fetch.minimax.ts +++ b/src/infra/provider-usage.fetch.minimax.ts @@ -1,5 +1,5 @@ import { isRecord } from "../utils.js"; -import { fetchJson } from "./provider-usage.fetch.shared.js"; +import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; @@ -224,10 +224,7 @@ function collectUsageCandidates(root: Record): Record): number | null { if (percentRaw !== undefined) { const normalized = clampPercent(percentRaw <= 1 ? percentRaw * 100 : percentRaw); if (fromCounts !== null) { - const inverted = clampPercent(100 - normalized); - if (Math.abs(normalized - fromCounts) <= 1 || Math.abs(inverted - fromCounts) <= 1) { - return fromCounts; - } + // Count-derived usage is more stable across provider percent field variations. return fromCounts; } return normalized; @@ -324,12 +318,10 @@ export async function fetchMinimaxUsage( ); if (!res.ok) { - return { + return buildUsageHttpErrorSnapshot({ provider: "minimax", - displayName: PROVIDER_LABELS.minimax, - windows: [], - error: `HTTP ${res.status}`, - }; + status: res.status, + }); } const data = (await res.json().catch(() => null)) as MinimaxUsageResponse; diff --git a/src/infra/provider-usage.fetch.shared.test.ts b/src/infra/provider-usage.fetch.shared.test.ts new file mode 100644 index 0000000000..d41c7e079a --- /dev/null +++ b/src/infra/provider-usage.fetch.shared.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + buildUsageErrorSnapshot, + buildUsageHttpErrorSnapshot, +} from "./provider-usage.fetch.shared.js"; + +describe("provider usage fetch shared helpers", () => { + it("builds a provider error snapshot", () => { + expect(buildUsageErrorSnapshot("zai", "API error")).toEqual({ + provider: "zai", + displayName: "z.ai", + windows: [], + error: "API error", + }); + }); + + it("maps configured status codes to token expired", () => { + const snapshot = buildUsageHttpErrorSnapshot({ + provider: "openai-codex", + status: 401, + tokenExpiredStatuses: [401, 403], + }); + + expect(snapshot.error).toBe("Token expired"); + expect(snapshot.provider).toBe("openai-codex"); + expect(snapshot.windows).toHaveLength(0); + }); + + it("includes trimmed API error messages in HTTP errors", () => { + const snapshot = buildUsageHttpErrorSnapshot({ + provider: "anthropic", + status: 403, + message: " missing scope ", + }); + + expect(snapshot.error).toBe("HTTP 403: missing scope"); + }); +}); diff --git a/src/infra/provider-usage.fetch.shared.ts b/src/infra/provider-usage.fetch.shared.ts index a4eb1ee630..0ce82ee9cb 100644 --- a/src/infra/provider-usage.fetch.shared.ts +++ b/src/infra/provider-usage.fetch.shared.ts @@ -1,3 +1,6 @@ +import { PROVIDER_LABELS } from "./provider-usage.shared.js"; +import type { ProviderUsageSnapshot, UsageProviderId } from "./provider-usage.types.js"; + export async function fetchJson( url: string, init: RequestInit, @@ -12,3 +15,33 @@ export async function fetchJson( clearTimeout(timer); } } + +type BuildUsageHttpErrorSnapshotOptions = { + provider: UsageProviderId; + status: number; + message?: string; + tokenExpiredStatuses?: readonly number[]; +}; + +export function buildUsageErrorSnapshot( + provider: UsageProviderId, + error: string, +): ProviderUsageSnapshot { + return { + provider, + displayName: PROVIDER_LABELS[provider], + windows: [], + error, + }; +} + +export function buildUsageHttpErrorSnapshot( + options: BuildUsageHttpErrorSnapshotOptions, +): ProviderUsageSnapshot { + const tokenExpiredStatuses = options.tokenExpiredStatuses ?? []; + if (tokenExpiredStatuses.includes(options.status)) { + return buildUsageErrorSnapshot(options.provider, "Token expired"); + } + const suffix = options.message?.trim() ? `: ${options.message.trim()}` : ""; + return buildUsageErrorSnapshot(options.provider, `HTTP ${options.status}${suffix}`); +} diff --git a/src/infra/provider-usage.fetch.zai.test.ts b/src/infra/provider-usage.fetch.zai.test.ts new file mode 100644 index 0000000000..2dafaccca9 --- /dev/null +++ b/src/infra/provider-usage.fetch.zai.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { fetchZaiUsage } from "./provider-usage.fetch.zai.js"; + +describe("fetchZaiUsage", () => { + it("returns HTTP errors for failed requests", async () => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(503, "unavailable")); + const result = await fetchZaiUsage("key", 5000, mockFetch); + + expect(result.error).toBe("HTTP 503"); + expect(result.windows).toHaveLength(0); + }); + + it("returns API message errors for unsuccessful payloads", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + success: false, + code: 500, + msg: "quota endpoint disabled", + }), + ); + + const result = await fetchZaiUsage("key", 5000, mockFetch); + expect(result.error).toBe("quota endpoint disabled"); + expect(result.windows).toHaveLength(0); + }); + + it("parses token and monthly windows with reset times", async () => { + const tokenReset = "2026-01-08T00:00:00Z"; + const minuteReset = "2026-01-08T00:30:00Z"; + const monthlyReset = "2026-01-31T12:00:00Z"; + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + success: true, + code: 200, + data: { + planName: "Team", + limits: [ + { + type: "TOKENS_LIMIT", + percentage: 32, + unit: 3, + number: 6, + nextResetTime: tokenReset, + }, + { + type: "TOKENS_LIMIT", + percentage: 8, + unit: 5, + number: 15, + nextResetTime: minuteReset, + }, + { + type: "TIME_LIMIT", + percentage: 12.5, + unit: 1, + number: 30, + nextResetTime: monthlyReset, + }, + ], + }, + }), + ); + + const result = await fetchZaiUsage("key", 5000, mockFetch); + + expect(result.plan).toBe("Team"); + expect(result.windows).toEqual([ + { + label: "Tokens (6h)", + usedPercent: 32, + resetAt: new Date(tokenReset).getTime(), + }, + { + label: "Tokens (15m)", + usedPercent: 8, + resetAt: new Date(minuteReset).getTime(), + }, + { + label: "Monthly", + usedPercent: 12.5, + resetAt: new Date(monthlyReset).getTime(), + }, + ]); + }); +}); diff --git a/src/infra/provider-usage.fetch.zai.ts b/src/infra/provider-usage.fetch.zai.ts index 97a7a9a90e..1ab1fd1476 100644 --- a/src/infra/provider-usage.fetch.zai.ts +++ b/src/infra/provider-usage.fetch.zai.ts @@ -1,4 +1,4 @@ -import { fetchJson } from "./provider-usage.fetch.shared.js"; +import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; @@ -38,12 +38,10 @@ export async function fetchZaiUsage( ); if (!res.ok) { - return { + return buildUsageHttpErrorSnapshot({ provider: "zai", - displayName: PROVIDER_LABELS.zai, - windows: [], - error: `HTTP ${res.status}`, - }; + status: res.status, + }); } const data = (await res.json()) as ZaiUsageResponse; diff --git a/src/test-utils/provider-usage-fetch.ts b/src/test-utils/provider-usage-fetch.ts new file mode 100644 index 0000000000..c20f3c6e5d --- /dev/null +++ b/src/test-utils/provider-usage-fetch.ts @@ -0,0 +1,27 @@ +import { vi } from "vitest"; +import { withFetchPreconnect } from "./fetch-mock.js"; + +type UsageFetchInput = string | Request | URL; +type UsageFetchHandler = (url: string, init?: RequestInit) => Promise | Response; +type UsageFetchMock = ReturnType< + typeof vi.fn<(input: UsageFetchInput, init?: RequestInit) => Promise> +>; + +export function makeResponse(status: number, body: unknown): Response { + const payload = typeof body === "string" ? body : JSON.stringify(body); + const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); +} + +export function toRequestUrl(input: UsageFetchInput): string { + return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; +} + +export function createProviderUsageFetch( + handler: UsageFetchHandler, +): typeof fetch & UsageFetchMock { + const mockFetch = vi.fn(async (input: UsageFetchInput, init?: RequestInit) => + handler(toRequestUrl(input), init), + ); + return withFetchPreconnect(mockFetch) as typeof fetch & UsageFetchMock; +}