Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/readme/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ When Claude Code does not expose quota windows itself, quota is read from Anthro

When that OAuth response includes enabled Usage Credits with numeric utilization, quota displays show a separate monthly **Claude Usage Credits** group; missing or invalid credit data leaves the regular 5-hour and weekly rows unchanged.

If that response includes Anthropic's model-scoped Fable weekly window, OpenCode Quota shows it as a separate `Fable` row. The row is omitted when Anthropic does not return the window; OpenCode Quota does not infer eligibility from the account's plan name. See [Claude Fable models on your plan](https://support.claude.com/en/articles/15424964-claude-fable-models-on-your-plan) for Anthropic's current eligibility rules.

<a id="cursor"></a>

### Cursor
Expand Down
61 changes: 59 additions & 2 deletions src/lib/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,15 @@ export interface AnthropicUsageResponse {
five_hour: AnthropicQuotaWindow;
seven_day: AnthropicQuotaWindow;
extra_usage?: AnthropicExtraUsage;
limits?: unknown[];
}

export interface AnthropicQuotaResult {
success: true;
five_hour: { percentRemaining: number; resetTimeIso?: string };
seven_day: { percentRemaining: number; resetTimeIso?: string };
extra_usage?: { percentRemaining: number };
fable_weekly?: { percentRemaining: number; resetTimeIso?: string };
}

export interface AnthropicUsageParseOptions {
Expand Down Expand Up @@ -382,6 +384,38 @@ function getUsageRoots(data: unknown): Record<string, unknown>[] {
return roots;
}

function parseFableWeeklyWindow(
limits: unknown,
): { percentRemaining: number; resetTimeIso?: string } | undefined {
if (!Array.isArray(limits)) {
return undefined;
}

for (const value of limits) {
const limit = asRecord(value);
if (!limit || limit["kind"] !== "weekly_scoped") {
continue;
}

const scope = asRecord(limit["scope"]);
const model = asRecord(scope?.["model"]);
const displayName = model?.["display_name"];
if (displayName !== "Fable") {
continue;
}

const window = parseQuotaWindow({
utilization: limit["percent"],
resets_at: limit["resets_at"],
});
if (window) {
return window;
}
}

return undefined;
}

function parseUsageResponse(
data: unknown,
options: AnthropicUsageParseOptions = {},
Expand Down Expand Up @@ -409,6 +443,29 @@ function parseUsageResponse(
return null;
}

function parseOAuthUsageResponse(data: unknown): AnthropicQuotaResult | null {
for (const root of getUsageRoots(data)) {
const fiveHour = parseQuotaWindow(root["five_hour"] ?? root["fiveHour"]);
const sevenDay = parseQuotaWindow(root["seven_day"] ?? root["sevenDay"]);

if (!fiveHour || !sevenDay) {
continue;
}

const extraUsage = parseExtraUsageQuota(root["extra_usage"]);
const fableWeekly = parseFableWeeklyWindow(root["limits"]);
return {
success: true,
five_hour: fiveHour,
seven_day: sevenDay,
...(extraUsage ? { extra_usage: extraUsage } : {}),
...(fableWeekly ? { fable_weekly: fableWeekly } : {}),
};
}

return null;
}

function getClaudeCredentialsPath(): string {
return join(homedir(), ".claude", ".credentials.json");
}
Expand Down Expand Up @@ -804,7 +861,7 @@ async function performAnthropicOAuthUsageRequest(
};
}

const quota = parseUsageResponse(data, { includeExtraUsage: true });
const quota = parseOAuthUsageResponse(data);
if (!quota) {
return {
state: "unavailable",
Expand Down Expand Up @@ -1441,4 +1498,4 @@ export async function queryAnthropicQuota(
}
}

export { parseUsageResponse };
export { parseOAuthUsageResponse, parseUsageResponse };
23 changes: 23 additions & 0 deletions src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export const anthropicProvider: QuotaProvider = {
seven_day_remaining: quota
? `${quota.seven_day.percentRemaining}% reset_at=${quota.seven_day.resetTimeIso ?? "(none)"}`
: undefined,
fable_weekly_remaining: quota?.fable_weekly
? `${quota.fable_weekly.percentRemaining}% reset_at=${quota.fable_weekly.resetTimeIso ?? "(none)"}`
: undefined,
});
} catch (error) {
statusDetails = statusDetailsFromRecord({
Expand Down Expand Up @@ -140,6 +143,26 @@ export const anthropicProvider: QuotaProvider = {
});
}

if (result.fable_weekly) {
entries.push({
accounting: {
resultType: "quota",
acquisitionMethod,
ownership: "maintained",
authority: "provider_reported",
},
name: "Claude Fable Weekly",
group: "Claude",
label: "Fable:",
semantic: {
metric: { kind: "named", name: "Fable weekly" },
prominence: "primary",
},
percentRemaining: result.fable_weekly.percentRemaining,
resetTimeIso: result.fable_weekly.resetTimeIso,
});
}

return withStatusDetails(attemptedResult(entries), statusDetails);
},
};
42 changes: 42 additions & 0 deletions tests/fixtures/anthropic/fable-weekly.sanitized.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"five_hour": {
"utilization": 42,
"resets_at": "2026-07-21T14:10:00.268668+00:00"
},
"seven_day": {
"utilization": 28,
"resets_at": "2026-07-27T07:00:00.268694+00:00"
},
"limits": [
{
"kind": "session",
"group": "session",
"percent": 42,
"resets_at": "2026-07-21T14:10:00.268668+00:00",
"scope": null,
"is_active": true
},
{
"kind": "weekly_all",
"group": "weekly",
"percent": 28,
"resets_at": "2026-07-27T07:00:00.268694+00:00",
"scope": null,
"is_active": false
},
{
"kind": "weekly_scoped",
"group": "weekly",
"percent": 2,
"resets_at": "2026-07-27T07:00:00.268958+00:00",
"scope": {
"model": {
"id": null,
"display_name": "Fable"
},
"surface": null
},
"is_active": false
}
]
}
104 changes: 87 additions & 17 deletions tests/lib.anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ import {
clearAnthropicDiagnosticsCacheForTests,
getAnthropicDiagnostics,
hasAnthropicCredentialsConfigured,
parseOAuthUsageResponse,
parseUsageResponse,
queryAnthropicQuota,
resolveAnthropicAuthIdentity,
} from "../src/lib/anthropic.js";
import { fetchWithTimeout } from "../src/lib/http.js";
import fableWeeklyUsage from "./fixtures/anthropic/fable-weekly.sanitized.json";

vi.mock("child_process", () => ({
execFile: vi.fn(),
Expand Down Expand Up @@ -271,6 +273,78 @@ describe("parseUsageResponse", () => {
expect(result?.extra_usage).toBeUndefined();
});

it("parses the exact Fable weekly_scoped window from a sanitized response", () => {
const result = parseOAuthUsageResponse(fableWeeklyUsage);

expect(parseUsageResponse(fableWeeklyUsage)).not.toHaveProperty("fable_weekly");
expect(result).toEqual({
success: true,
five_hour: {
percentRemaining: 58,
resetTimeIso: "2026-07-21T14:10:00.268Z",
},
seven_day: {
percentRemaining: 72,
resetTimeIso: "2026-07-27T07:00:00.268Z",
},
fable_weekly: {
percentRemaining: 98,
resetTimeIso: "2026-07-27T07:00:00.268Z",
},
});
});

it("keeps the existing windows when Fable scoped data is absent or malformed", () => {
const withoutFable = parseOAuthUsageResponse({
five_hour: { utilization: 20 },
seven_day: { utilization: 30 },
limits: [
{
kind: "weekly_scoped",
percent: 40,
scope: { model: { display_name: "Opus" } },
},
{
kind: "weekly_scoped",
percent: 40,
scope: { model: { display_name: "Claude Fable 5" } },
},
{
kind: "weekly_scoped",
percent: "not-a-number",
scope: { model: { display_name: "Fable" } },
},
],
});

expect(withoutFable).toEqual({
success: true,
five_hour: { percentRemaining: 80, resetTimeIso: undefined },
seven_day: { percentRemaining: 70, resetTimeIso: undefined },
});
});

it("accepts a zero-percent Fable window without a reset timestamp", () => {
const result = parseOAuthUsageResponse({
five_hour: { utilization: 20 },
seven_day: { utilization: 30 },
limits: [
{
kind: "weekly_scoped",
percent: 0,
resets_at: null,
scope: { model: { display_name: "Fable" } },
is_active: false,
},
],
});

expect(result?.fable_weekly).toEqual({
percentRemaining: 100,
resetTimeIso: undefined,
});
});

it("drops invalid reset timestamps and only caps percent remaining above 100", () => {
const result = parseUsageResponse({
usage: {
Expand Down Expand Up @@ -648,17 +722,8 @@ describe("Claude CLI diagnostics", () => {
);
fetchResponseMock.mockResolvedValue(
mockJsonResponse({
oauth_usage: {
fiveHour: {
usedPercent: 35,
resetAt: "2026-03-25T18:00:00.000Z",
},
sevenDay: {
percent_used: 15,
resetsAt: "2026-04-01T00:00:00.000Z",
},
extra_usage: { is_enabled: true, utilization: 37.8 },
},
...fableWeeklyUsage,
extra_usage: { is_enabled: true, utilization: 37.8 },
}),
);

Expand All @@ -667,11 +732,15 @@ describe("Claude CLI diagnostics", () => {
expect(diagnostics.authStatus).toBe("authenticated");
expect(diagnostics.quotaSupported).toBe(true);
expect(diagnostics.quotaSource).toBe("claude-credentials-oauth-api");
expect(diagnostics.quota?.five_hour.percentRemaining).toBe(65);
expect(diagnostics.quota?.five_hour.resetTimeIso).toBe("2026-03-25T18:00:00.000Z");
expect(diagnostics.quota?.seven_day.percentRemaining).toBe(85);
expect(diagnostics.quota?.seven_day.resetTimeIso).toBe("2026-04-01T00:00:00.000Z");
expect(diagnostics.quota?.five_hour.percentRemaining).toBe(58);
expect(diagnostics.quota?.five_hour.resetTimeIso).toBe("2026-07-21T14:10:00.268Z");
expect(diagnostics.quota?.seven_day.percentRemaining).toBe(72);
expect(diagnostics.quota?.seven_day.resetTimeIso).toBe("2026-07-27T07:00:00.268Z");
expect(diagnostics.quota?.extra_usage).toEqual({ percentRemaining: 62 });
expect(diagnostics.quota?.fable_weekly).toEqual({
percentRemaining: 98,
resetTimeIso: "2026-07-27T07:00:00.268Z",
});
expect(fetchWithTimeoutMock).toHaveBeenCalledWith(ANTHROPIC_USAGE_URL, {
request: {
headers: {
Expand All @@ -686,8 +755,9 @@ describe("Claude CLI diagnostics", () => {
const quota = await queryAnthropicQuota();
expect(quota?.success).toBe(true);
if (quota?.success) {
expect(quota.five_hour.percentRemaining).toBe(65);
expect(quota.seven_day.percentRemaining).toBe(85);
expect(quota.five_hour.percentRemaining).toBe(58);
expect(quota.seven_day.percentRemaining).toBe(72);
expect(quota.fable_weekly?.percentRemaining).toBe(98);
}

expect(execFileMock).toHaveBeenCalledTimes(2);
Expand Down
Loading