Skip to content

Commit badafdc

Browse files
committed
refactor: dedupe provider usage fetch logic and tests
1 parent 6195660 commit badafdc

16 files changed

Lines changed: 806 additions & 215 deletions

src/infra/provider-usage.fetch.antigravity.test.ts

Lines changed: 133 additions & 161 deletions
Large diffs are not rendered by default.
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js";
3+
import { fetchClaudeUsage } from "./provider-usage.fetch.claude.js";
4+
5+
const MISSING_SCOPE_MESSAGE = "missing scope requirement user:profile";
6+
7+
function makeMissingScopeResponse() {
8+
return makeResponse(403, {
9+
error: { message: MISSING_SCOPE_MESSAGE },
10+
});
11+
}
12+
13+
function expectMissingScopeError(result: Awaited<ReturnType<typeof fetchClaudeUsage>>) {
14+
expect(result.error).toBe(`HTTP 403: ${MISSING_SCOPE_MESSAGE}`);
15+
expect(result.windows).toHaveLength(0);
16+
}
17+
18+
function createScopeFallbackFetch(handler: (url: string) => Promise<Response> | Response) {
19+
return createProviderUsageFetch(async (url) => {
20+
if (url.includes("/api/oauth/usage")) {
21+
return makeMissingScopeResponse();
22+
}
23+
return handler(url);
24+
});
25+
}
26+
27+
type ScopeFallbackFetch = ReturnType<typeof createScopeFallbackFetch>;
28+
29+
async function expectMissingScopeWithoutFallback(mockFetch: ScopeFallbackFetch) {
30+
const result = await fetchClaudeUsage("token", 5000, mockFetch);
31+
expectMissingScopeError(result);
32+
expect(mockFetch).toHaveBeenCalledTimes(1);
33+
}
34+
35+
function makeOrgAResponse() {
36+
return makeResponse(200, [{ uuid: "org-a" }]);
37+
}
38+
39+
describe("fetchClaudeUsage", () => {
40+
afterEach(() => {
41+
vi.unstubAllEnvs();
42+
});
43+
44+
it("parses oauth usage windows", async () => {
45+
const fiveHourReset = "2026-01-08T00:00:00Z";
46+
const weekReset = "2026-01-12T00:00:00Z";
47+
const mockFetch = createProviderUsageFetch(async (_url, init) => {
48+
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
49+
expect(headers.Authorization).toBe("Bearer token");
50+
expect(headers["anthropic-beta"]).toBe("oauth-2025-04-20");
51+
52+
return makeResponse(200, {
53+
five_hour: { utilization: 18, resets_at: fiveHourReset },
54+
seven_day: { utilization: 54, resets_at: weekReset },
55+
seven_day_sonnet: { utilization: 67 },
56+
});
57+
});
58+
59+
const result = await fetchClaudeUsage("token", 5000, mockFetch);
60+
61+
expect(result.windows).toEqual([
62+
{ label: "5h", usedPercent: 18, resetAt: new Date(fiveHourReset).getTime() },
63+
{ label: "Week", usedPercent: 54, resetAt: new Date(weekReset).getTime() },
64+
{ label: "Sonnet", usedPercent: 67 },
65+
]);
66+
});
67+
68+
it("returns HTTP errors with provider message suffix", async () => {
69+
const mockFetch = createProviderUsageFetch(async () =>
70+
makeResponse(403, {
71+
error: { message: "scope not granted" },
72+
}),
73+
);
74+
75+
const result = await fetchClaudeUsage("token", 5000, mockFetch);
76+
expect(result.error).toBe("HTTP 403: scope not granted");
77+
expect(result.windows).toHaveLength(0);
78+
});
79+
80+
it("falls back to claude web usage when oauth scope is missing", async () => {
81+
vi.stubEnv("CLAUDE_AI_SESSION_KEY", "sk-ant-session-key");
82+
83+
const mockFetch = createProviderUsageFetch(async (url, init) => {
84+
if (url.includes("/api/oauth/usage")) {
85+
return makeMissingScopeResponse();
86+
}
87+
88+
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
89+
expect(headers.Cookie).toBe("sessionKey=sk-ant-session-key");
90+
91+
if (url.endsWith("/api/organizations")) {
92+
return makeResponse(200, [{ uuid: "org-123" }]);
93+
}
94+
95+
if (url.endsWith("/api/organizations/org-123/usage")) {
96+
return makeResponse(200, {
97+
five_hour: { utilization: 12 },
98+
});
99+
}
100+
101+
return makeResponse(404, "not found");
102+
});
103+
104+
const result = await fetchClaudeUsage("token", 5000, mockFetch);
105+
106+
expect(result.error).toBeUndefined();
107+
expect(result.windows).toEqual([{ label: "5h", usedPercent: 12, resetAt: undefined }]);
108+
});
109+
110+
it("keeps oauth error when cookie header cannot be parsed into a session key", async () => {
111+
vi.stubEnv("CLAUDE_WEB_COOKIE", "sessionKey=sk-ant-cookie-session");
112+
113+
const mockFetch = createScopeFallbackFetch(async (url) => {
114+
if (url.endsWith("/api/organizations")) {
115+
return makeResponse(200, [{ uuid: "org-cookie" }]);
116+
}
117+
if (url.endsWith("/api/organizations/org-cookie/usage")) {
118+
return makeResponse(200, { seven_day_opus: { utilization: 44 } });
119+
}
120+
return makeResponse(404, "not found");
121+
});
122+
123+
await expectMissingScopeWithoutFallback(mockFetch);
124+
});
125+
126+
it("keeps oauth error when fallback session key is unavailable", async () => {
127+
const mockFetch = createScopeFallbackFetch(async (url) => {
128+
if (url.endsWith("/api/organizations")) {
129+
return makeResponse(200, [{ uuid: "org-missing-session" }]);
130+
}
131+
return makeResponse(404, "not found");
132+
});
133+
134+
await expectMissingScopeWithoutFallback(mockFetch);
135+
});
136+
137+
it.each([
138+
{
139+
name: "org list request fails",
140+
orgResponse: () => makeResponse(500, "boom"),
141+
usageResponse: () => makeResponse(200, {}),
142+
},
143+
{
144+
name: "org list has no id",
145+
orgResponse: () => makeResponse(200, [{}]),
146+
usageResponse: () => makeResponse(200, {}),
147+
},
148+
{
149+
name: "usage request fails",
150+
orgResponse: makeOrgAResponse,
151+
usageResponse: () => makeResponse(503, "down"),
152+
},
153+
{
154+
name: "usage request has no windows",
155+
orgResponse: makeOrgAResponse,
156+
usageResponse: () => makeResponse(200, {}),
157+
},
158+
])(
159+
"returns oauth error when web fallback is unavailable: $name",
160+
async ({ orgResponse, usageResponse }) => {
161+
vi.stubEnv("CLAUDE_AI_SESSION_KEY", "sk-ant-fallback");
162+
163+
const mockFetch = createScopeFallbackFetch(async (url) => {
164+
if (url.endsWith("/api/organizations")) {
165+
return orgResponse();
166+
}
167+
if (url.endsWith("/api/organizations/org-a/usage")) {
168+
return usageResponse();
169+
}
170+
return makeResponse(404, "not found");
171+
});
172+
173+
const result = await fetchClaudeUsage("token", 5000, mockFetch);
174+
expectMissingScopeError(result);
175+
},
176+
);
177+
});

src/infra/provider-usage.fetch.claude.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fetchJson } from "./provider-usage.fetch.shared.js";
1+
import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js";
22
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
33
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
44

@@ -159,13 +159,11 @@ export async function fetchClaudeUsage(
159159
}
160160
}
161161

162-
const suffix = message ? `: ${message}` : "";
163-
return {
162+
return buildUsageHttpErrorSnapshot({
164163
provider: "anthropic",
165-
displayName: PROVIDER_LABELS.anthropic,
166-
windows: [],
167-
error: `HTTP ${res.status}${suffix}`,
168-
};
164+
status: res.status,
165+
message,
166+
});
169167
}
170168

171169
const data = (await res.json()) as ClaudeUsageResponse;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js";
3+
import { fetchCodexUsage } from "./provider-usage.fetch.codex.js";
4+
5+
describe("fetchCodexUsage", () => {
6+
it("returns token expired for auth failures", async () => {
7+
const mockFetch = createProviderUsageFetch(async () =>
8+
makeResponse(401, { error: "unauthorized" }),
9+
);
10+
11+
const result = await fetchCodexUsage("token", undefined, 5000, mockFetch);
12+
expect(result.error).toBe("Token expired");
13+
expect(result.windows).toHaveLength(0);
14+
});
15+
16+
it("returns HTTP status errors for non-auth failures", async () => {
17+
const mockFetch = createProviderUsageFetch(async () =>
18+
makeResponse(429, { error: "throttled" }),
19+
);
20+
21+
const result = await fetchCodexUsage("token", undefined, 5000, mockFetch);
22+
expect(result.error).toBe("HTTP 429");
23+
expect(result.windows).toHaveLength(0);
24+
});
25+
26+
it("parses windows, reset times, and plan balance", async () => {
27+
const mockFetch = createProviderUsageFetch(async (_url, init) => {
28+
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
29+
expect(headers["ChatGPT-Account-Id"]).toBe("acct-1");
30+
return makeResponse(200, {
31+
rate_limit: {
32+
primary_window: {
33+
limit_window_seconds: 10_800,
34+
used_percent: 35.5,
35+
reset_at: 1_700_000_000,
36+
},
37+
secondary_window: {
38+
limit_window_seconds: 86_400,
39+
used_percent: 75,
40+
reset_at: 1_700_050_000,
41+
},
42+
},
43+
plan_type: "Plus",
44+
credits: { balance: "12.5" },
45+
});
46+
});
47+
48+
const result = await fetchCodexUsage("token", "acct-1", 5000, mockFetch);
49+
50+
expect(result.provider).toBe("openai-codex");
51+
expect(result.plan).toBe("Plus ($12.50)");
52+
expect(result.windows).toEqual([
53+
{ label: "3h", usedPercent: 35.5, resetAt: 1_700_000_000_000 },
54+
{ label: "Day", usedPercent: 75, resetAt: 1_700_050_000_000 },
55+
]);
56+
});
57+
});

src/infra/provider-usage.fetch.codex.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fetchJson } from "./provider-usage.fetch.shared.js";
1+
import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js";
22
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
33
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
44

@@ -41,22 +41,12 @@ export async function fetchCodexUsage(
4141
fetchFn,
4242
);
4343

44-
if (res.status === 401 || res.status === 403) {
45-
return {
46-
provider: "openai-codex",
47-
displayName: PROVIDER_LABELS["openai-codex"],
48-
windows: [],
49-
error: "Token expired",
50-
};
51-
}
52-
5344
if (!res.ok) {
54-
return {
45+
return buildUsageHttpErrorSnapshot({
5546
provider: "openai-codex",
56-
displayName: PROVIDER_LABELS["openai-codex"],
57-
windows: [],
58-
error: `HTTP ${res.status}`,
59-
};
47+
status: res.status,
48+
tokenExpiredStatuses: [401, 403],
49+
});
6050
}
6151

6252
const data = (await res.json()) as CodexUsageResponse;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js";
3+
import { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js";
4+
5+
describe("fetchCopilotUsage", () => {
6+
it("returns HTTP errors for failed requests", async () => {
7+
const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom"));
8+
const result = await fetchCopilotUsage("token", 5000, mockFetch);
9+
10+
expect(result.error).toBe("HTTP 500");
11+
expect(result.windows).toHaveLength(0);
12+
});
13+
14+
it("parses premium/chat usage from remaining percentages", async () => {
15+
const mockFetch = createProviderUsageFetch(async (_url, init) => {
16+
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
17+
expect(headers.Authorization).toBe("token token");
18+
expect(headers["X-Github-Api-Version"]).toBe("2025-04-01");
19+
20+
return makeResponse(200, {
21+
quota_snapshots: {
22+
premium_interactions: { percent_remaining: 20 },
23+
chat: { percent_remaining: 75 },
24+
},
25+
copilot_plan: "pro",
26+
});
27+
});
28+
29+
const result = await fetchCopilotUsage("token", 5000, mockFetch);
30+
31+
expect(result.plan).toBe("pro");
32+
expect(result.windows).toEqual([
33+
{ label: "Premium", usedPercent: 80 },
34+
{ label: "Chat", usedPercent: 25 },
35+
]);
36+
});
37+
});

src/infra/provider-usage.fetch.copilot.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fetchJson } from "./provider-usage.fetch.shared.js";
1+
import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js";
22
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
33
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
44

@@ -30,12 +30,10 @@ export async function fetchCopilotUsage(
3030
);
3131

3232
if (!res.ok) {
33-
return {
33+
return buildUsageHttpErrorSnapshot({
3434
provider: "github-copilot",
35-
displayName: PROVIDER_LABELS["github-copilot"],
36-
windows: [],
37-
error: `HTTP ${res.status}`,
38-
};
35+
status: res.status,
36+
});
3937
}
4038

4139
const data = (await res.json()) as CopilotUsageResponse;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js";
3+
import { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js";
4+
5+
describe("fetchGeminiUsage", () => {
6+
it("returns HTTP errors for failed requests", async () => {
7+
const mockFetch = createProviderUsageFetch(async () =>
8+
makeResponse(429, { error: "rate_limited" }),
9+
);
10+
const result = await fetchGeminiUsage("token", 5000, mockFetch, "google-gemini-cli");
11+
12+
expect(result.error).toBe("HTTP 429");
13+
expect(result.windows).toHaveLength(0);
14+
});
15+
16+
it("selects the lowest remaining fraction per model family", async () => {
17+
const mockFetch = createProviderUsageFetch(async (_url, init) => {
18+
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
19+
expect(headers.Authorization).toBe("Bearer token");
20+
21+
return makeResponse(200, {
22+
buckets: [
23+
{ modelId: "gemini-pro", remainingFraction: 0.8 },
24+
{ modelId: "gemini-pro-preview", remainingFraction: 0.3 },
25+
{ modelId: "gemini-flash", remainingFraction: 0.7 },
26+
{ modelId: "gemini-flash-latest", remainingFraction: 0.9 },
27+
{ modelId: "gemini-unknown", remainingFraction: 0.5 },
28+
],
29+
});
30+
});
31+
32+
const result = await fetchGeminiUsage("token", 5000, mockFetch, "google-gemini-cli");
33+
34+
expect(result.windows).toHaveLength(2);
35+
expect(result.windows[0]).toEqual({ label: "Pro", usedPercent: 70 });
36+
expect(result.windows[1]?.label).toBe("Flash");
37+
expect(result.windows[1]?.usedPercent).toBeCloseTo(30, 6);
38+
});
39+
});

0 commit comments

Comments
 (0)