feat(quota): read Claude plan limits from the Claude Code login
Claude quota only worked when the user had signed into Anthropic through OpenCode. Credentials are now discovered from Claude Code itself first: the macOS Keychain entry, then the Linux/WSL credentials file (honouring CLAUDE_CONFIG_DIR), then OpenCode auth.json, then CLAUDE_CODE_OAUTH_TOKEN. All sources stay read-only and the OAuth token is never refreshed: Anthropic allows one live refresh token per client_id, so refreshing here would sign the user out of Claude Code. Credentials are re-read per request instead, and an expired token reports that Claude Code needs a sign-in rather than a bare 401. Usage is now read from the limits[] array, so model-scoped weekly limits work again after Anthropic stopped populating seven_day_sonnet/seven_day_opus, and new limit kinds no longer need a code change. Adds extra-usage spend and the plan name, and holds the last good values through Anthropic's 429s with a cooldown and an account-keyed cache.
This commit is contained in:
@@ -8,6 +8,7 @@ This module fetches quota and usage signals for supported providers in the web s
|
||||
- `packages/web/server/lib/quota/routes.js`: Express route registration for quota endpoints.
|
||||
- `packages/web/server/lib/quota/providers/index.js`: provider registry, configured-provider list, and provider dispatcher.
|
||||
- `packages/web/server/lib/quota/providers/google/`: Google-specific auth, API, and transform modules.
|
||||
- `packages/web/server/lib/quota/providers/claude/`: Claude credential discovery, usage transforms, and rate-limit handling.
|
||||
- `packages/web/server/lib/quota/utils/`: shared auth, transform, and formatting helpers.
|
||||
|
||||
## Supported provider IDs (dispatcher)
|
||||
@@ -16,7 +17,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
|
||||
| Provider ID | Display name | Module | Auth aliases/keys |
|
||||
| --- | --- | --- | --- |
|
||||
| `claude` | Claude | `providers/claude.js` | `anthropic`, `claude` |
|
||||
| `claude` | Claude | `providers/claude/` | Claude Code Keychain entry, Claude Code credentials file, OpenCode `auth.json` (`anthropic`, `claude`), `CLAUDE_CODE_OAUTH_TOKEN` |
|
||||
| `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
|
||||
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
|
||||
| `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) |
|
||||
@@ -53,6 +54,16 @@ Ollama Cloud and Cursor credentials are explicitly managed through Settings. Ope
|
||||
|
||||
On the first OpenCode Go usage refresh after upgrading, OpenChamber deletes the obsolete `quota/opencode-go.json` credential file without reading its cookie value.
|
||||
|
||||
## Claude credential and limit semantics
|
||||
|
||||
Claude quota reports the subscription limits Claude Code itself is bound by, read from `GET https://api.anthropic.com/api/oauth/usage`.
|
||||
|
||||
- **Credential sources**, in priority order: the macOS Keychain entry `Claude Code-credentials`, then `${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json` (the Linux/WSL location), then the OpenCode `auth.json` entry, then `CLAUDE_CODE_OAUTH_TOKEN`. The Keychain wins on macOS because the credentials file there is a leftover Claude Code no longer updates.
|
||||
- **All sources are read-only.** OpenChamber never writes to Claude Code's credential store and never refreshes the OAuth token, because Anthropic does not support two live refresh tokens for one `client_id` — refreshing here would sign the user out of Claude Code. Credentials are read fresh per request so a Claude Code refresh is picked up immediately; an expired token yields an explicit "open Claude Code to sign in again" error rather than a bare 401.
|
||||
- **Limits come from the `limits` array**, keyed by `kind`: `session` maps to the `5h` window, `weekly_all` to `7d`, and `weekly_scoped` to a per-model `7d` window named by `scope.model.display_name`. The legacy `five_hour`/`seven_day` fields are only a fallback; `seven_day_sonnet`/`seven_day_opus` are no longer populated by Anthropic. Unrecognized limit kinds and Anthropic's rotating internal code names (`nimbus_quill`, `tangelo`, ...) are ignored rather than guessed at.
|
||||
- **Extra usage** is reported as the `extra_usage` window from `spend`, only while `spend.enabled` is true, with a money `valueLabel`.
|
||||
- **Rate limiting**: Anthropic returns 429 aggressively. The last successful usage payload is cached in memory and reserved during a cooldown (`Retry-After`, else five minutes, capped at one hour). The cache is keyed by a hash of the access and refresh tokens, so switching accounts drops it instead of showing the previous account's numbers.
|
||||
|
||||
## Add a new provider (quick steps)
|
||||
1. Choose module shape based on complexity:
|
||||
- Simple providers: create `packages/web/server/lib/quota/providers/<provider>.js`.
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
toTimestamp
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'claude';
|
||||
export const providerName = 'Claude';
|
||||
const aliases = ['anthropic', 'claude'];
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
return Boolean(entry?.access || entry?.token);
|
||||
};
|
||||
|
||||
export const fetchQuota = async () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
const accessToken = entry?.access ?? entry?.token;
|
||||
|
||||
if (!accessToken) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'anthropic-beta': 'oauth-2025-04-20'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: `API error: ${response.status}`
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const windows = {};
|
||||
const fiveHour = payload?.five_hour ?? null;
|
||||
const sevenDay = payload?.seven_day ?? null;
|
||||
const sevenDaySonnet = payload?.seven_day_sonnet ?? null;
|
||||
const sevenDayOpus = payload?.seven_day_opus ?? null;
|
||||
|
||||
if (fiveHour) {
|
||||
windows['5h'] = toUsageWindow({
|
||||
usedPercent: toNumber(fiveHour.utilization),
|
||||
windowSeconds: null,
|
||||
resetAt: toTimestamp(fiveHour.resets_at)
|
||||
});
|
||||
}
|
||||
if (sevenDay) {
|
||||
windows['7d'] = toUsageWindow({
|
||||
usedPercent: toNumber(sevenDay.utilization),
|
||||
windowSeconds: null,
|
||||
resetAt: toTimestamp(sevenDay.resets_at)
|
||||
});
|
||||
}
|
||||
if (sevenDaySonnet) {
|
||||
windows['7d-sonnet'] = toUsageWindow({
|
||||
usedPercent: toNumber(sevenDaySonnet.utilization),
|
||||
windowSeconds: null,
|
||||
resetAt: toTimestamp(sevenDaySonnet.resets_at)
|
||||
});
|
||||
}
|
||||
if (sevenDayOpus) {
|
||||
windows['7d-opus'] = toUsageWindow({
|
||||
usedPercent: toNumber(sevenDayOpus.utilization),
|
||||
windowSeconds: null,
|
||||
resetAt: toTimestamp(sevenDayOpus.resets_at)
|
||||
});
|
||||
}
|
||||
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows }
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: error instanceof Error ? error.message : 'Request failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Claude credential discovery.
|
||||
*
|
||||
* Claude Code is the primary source: on macOS it keeps its OAuth tokens in the
|
||||
* login Keychain, elsewhere in a credentials file. OpenCode's own `auth.json`
|
||||
* entry is the fallback for users who signed into Anthropic through OpenCode
|
||||
* instead of Claude Code.
|
||||
*
|
||||
* Every source is read-only. Claude rotates a Keychain/credentials entry from
|
||||
* under us whenever Claude Code refreshes, so credentials are read fresh per
|
||||
* request rather than cached; a stale cached token would outlive the record it
|
||||
* came from.
|
||||
*
|
||||
* @module quota/providers/claude/auth
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { readAuthFile } from '../../../opencode/auth.js';
|
||||
import { asObject, asNonEmptyString, normalizeTimestamp, getAuthEntry, normalizeAuthEntry, readJsonFile } from '../../utils/index.js';
|
||||
|
||||
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
||||
const OPENCODE_AUTH_ALIASES = ['anthropic', 'claude'];
|
||||
|
||||
/**
|
||||
* @typedef {object} ClaudeCredential
|
||||
* @property {string} accessToken
|
||||
* @property {string|null} refreshToken
|
||||
* @property {number|null} expiresAt Epoch milliseconds, when the source reports it.
|
||||
* @property {string|null} planLabel Subscription tier reported by Claude Code, e.g. `max`.
|
||||
* @property {'keychain'|'credentials-file'|'opencode-auth'|'env'} source
|
||||
*/
|
||||
|
||||
const claudeConfigDirectory = () => {
|
||||
const override = asNonEmptyString(process.env.CLAUDE_CONFIG_DIR);
|
||||
return override ? path.resolve(override) : path.join(os.homedir(), '.claude');
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude Code writes one JSON blob holding both its own OAuth tokens
|
||||
* (`claudeAiOauth`) and unrelated MCP server tokens. Only the former is read.
|
||||
*/
|
||||
const parseClaudeCodeBlob = (blob, source) => {
|
||||
const oauth = asObject(asObject(blob)?.claudeAiOauth);
|
||||
const accessToken = asNonEmptyString(oauth?.accessToken);
|
||||
if (!accessToken) return null;
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: asNonEmptyString(oauth.refreshToken),
|
||||
expiresAt: normalizeTimestamp(oauth.expiresAt),
|
||||
planLabel: asNonEmptyString(oauth.subscriptionType),
|
||||
source
|
||||
};
|
||||
};
|
||||
|
||||
const readKeychainCredential = () => {
|
||||
if (process.platform !== 'darwin') return null;
|
||||
let raw;
|
||||
try {
|
||||
raw = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
});
|
||||
} catch {
|
||||
// No entry, or the user denied Keychain access. Both mean "try the next source".
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseClaudeCodeBlob(JSON.parse(raw.trim()), 'keychain');
|
||||
} catch {
|
||||
console.warn('Claude quota: Keychain credentials are not valid JSON');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readCredentialsFile = () =>
|
||||
parseClaudeCodeBlob(readJsonFile(path.join(claudeConfigDirectory(), '.credentials.json')), 'credentials-file');
|
||||
|
||||
const readOpenCodeCredential = () => {
|
||||
const entry = normalizeAuthEntry(getAuthEntry(readAuthFile(), OPENCODE_AUTH_ALIASES));
|
||||
const accessToken = asNonEmptyString(entry?.access) ?? asNonEmptyString(entry?.token);
|
||||
if (!accessToken) return null;
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: asNonEmptyString(entry.refresh),
|
||||
expiresAt: normalizeTimestamp(entry.expires),
|
||||
planLabel: null,
|
||||
source: 'opencode-auth'
|
||||
};
|
||||
};
|
||||
|
||||
const readEnvCredential = () => {
|
||||
const accessToken = asNonEmptyString(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
||||
if (!accessToken) return null;
|
||||
return { accessToken, refreshToken: null, expiresAt: null, planLabel: null, source: 'env' };
|
||||
};
|
||||
|
||||
/**
|
||||
* First credential a source can produce, in priority order.
|
||||
*
|
||||
* The Keychain wins over the credentials file because on macOS the file is a
|
||||
* leftover that Claude Code no longer updates.
|
||||
*
|
||||
* @returns {ClaudeCredential|null}
|
||||
*/
|
||||
export const loadClaudeCredential = () =>
|
||||
readKeychainCredential()
|
||||
?? readCredentialsFile()
|
||||
?? readOpenCodeCredential()
|
||||
?? readEnvCredential();
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const execFileSync = vi.fn();
|
||||
const files = new Map();
|
||||
const openCodeAuth = vi.fn(() => ({}));
|
||||
|
||||
vi.mock('child_process', () => ({ execFileSync: (...args) => execFileSync(...args) }));
|
||||
|
||||
vi.mock('fs', () => {
|
||||
const fs = {
|
||||
existsSync: (filePath) => files.has(filePath),
|
||||
readFileSync: (filePath) => {
|
||||
if (!files.has(filePath)) throw new Error('ENOENT');
|
||||
return files.get(filePath);
|
||||
},
|
||||
};
|
||||
return { ...fs, default: fs };
|
||||
});
|
||||
|
||||
vi.mock('../../../opencode/auth.js', () => ({ readAuthFile: () => openCodeAuth() }));
|
||||
|
||||
import { loadClaudeCredential } from './auth.js';
|
||||
|
||||
const claudeCodeBlob = (accessToken) => JSON.stringify({
|
||||
mcpOAuth: { 'linear|abc': { accessToken: 'unrelated-mcp-token' } },
|
||||
claudeAiOauth: {
|
||||
accessToken,
|
||||
refreshToken: `${accessToken}-refresh`,
|
||||
expiresAt: 1786735755912,
|
||||
subscriptionType: 'max',
|
||||
},
|
||||
});
|
||||
|
||||
const withPlatform = (platform, run) => {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', original);
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
files.clear();
|
||||
execFileSync.mockReset();
|
||||
execFileSync.mockImplementation(() => { throw new Error('no keychain entry'); });
|
||||
openCodeAuth.mockReturnValue({});
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
});
|
||||
|
||||
describe('Claude credential discovery', () => {
|
||||
it('prefers the macOS Keychain over a stale credentials file', () => {
|
||||
execFileSync.mockReturnValue(claudeCodeBlob('keychain-token'));
|
||||
files.set(`${process.env.HOME}/.claude/.credentials.json`, claudeCodeBlob('file-token'));
|
||||
|
||||
const credential = withPlatform('darwin', loadClaudeCredential);
|
||||
|
||||
expect(credential.accessToken).toBe('keychain-token');
|
||||
expect(credential.refreshToken).toBe('keychain-token-refresh');
|
||||
expect(credential.planLabel).toBe('max');
|
||||
expect(credential.source).toBe('keychain');
|
||||
});
|
||||
|
||||
it('reads the credentials file on Linux, where there is no Keychain', () => {
|
||||
files.set(`${process.env.HOME}/.claude/.credentials.json`, claudeCodeBlob('file-token'));
|
||||
|
||||
const credential = withPlatform('linux', loadClaudeCredential);
|
||||
|
||||
expect(execFileSync).not.toHaveBeenCalled();
|
||||
expect(credential.accessToken).toBe('file-token');
|
||||
expect(credential.source).toBe('credentials-file');
|
||||
});
|
||||
|
||||
it('honours CLAUDE_CONFIG_DIR when locating the credentials file', () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = '/tmp/claude-home';
|
||||
files.set('/tmp/claude-home/.credentials.json', claudeCodeBlob('custom-dir-token'));
|
||||
|
||||
expect(withPlatform('linux', loadClaudeCredential).accessToken).toBe('custom-dir-token');
|
||||
});
|
||||
|
||||
it('falls back to the OpenCode auth entry when Claude Code is not signed in', () => {
|
||||
openCodeAuth.mockReturnValue({ anthropic: { access: 'opencode-token', refresh: 'opencode-refresh', expires: 1786735755912 } });
|
||||
|
||||
const credential = withPlatform('linux', loadClaudeCredential);
|
||||
|
||||
expect(credential.accessToken).toBe('opencode-token');
|
||||
expect(credential.source).toBe('opencode-auth');
|
||||
expect(credential.planLabel).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to CLAUDE_CODE_OAUTH_TOKEN last, without a refresh token', () => {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-token';
|
||||
|
||||
const credential = withPlatform('linux', loadClaudeCredential);
|
||||
|
||||
expect(credential.accessToken).toBe('env-token');
|
||||
expect(credential.refreshToken).toBeNull();
|
||||
expect(credential.source).toBe('env');
|
||||
});
|
||||
|
||||
it('ignores a Keychain blob that only holds unrelated MCP tokens', () => {
|
||||
execFileSync.mockReturnValue(JSON.stringify({ mcpOAuth: { 'linear|abc': { accessToken: 'unrelated' } } }));
|
||||
|
||||
expect(withPlatform('darwin', loadClaudeCredential)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when every source is empty', () => {
|
||||
expect(withPlatform('darwin', loadClaudeCredential)).toBeNull();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const credential = vi.fn();
|
||||
|
||||
vi.mock('./auth.js', () => ({
|
||||
loadClaudeCredential: () => credential(),
|
||||
}));
|
||||
|
||||
import { fetchQuota, isConfigured, resetClaudeQuotaCache } from './index.js';
|
||||
|
||||
const CREDENTIAL = {
|
||||
accessToken: 'access-a',
|
||||
refreshToken: 'refresh-a',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
planLabel: 'max',
|
||||
source: 'keychain',
|
||||
};
|
||||
|
||||
const PAYLOAD = {
|
||||
limits: [
|
||||
{ kind: 'session', percent: 5, resets_at: '2026-08-14T19:10:00Z', scope: null },
|
||||
{ kind: 'weekly_all', percent: 4, resets_at: '2026-08-20T15:00:00Z', scope: null },
|
||||
],
|
||||
};
|
||||
|
||||
const jsonResponse = (body) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
const errorResponse = (status, headers = {}) => ({
|
||||
ok: false,
|
||||
status,
|
||||
headers: new Headers(headers),
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetClaudeQuotaCache();
|
||||
credential.mockReturnValue(CREDENTIAL);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('Claude quota provider', () => {
|
||||
it('reports not configured when no credential source has a token', async () => {
|
||||
credential.mockReturnValue(null);
|
||||
|
||||
expect(isConfigured()).toBe(false);
|
||||
const result = await fetchQuota();
|
||||
expect(result.configured).toBe(false);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it('returns windows and the plan label from the credential', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(PAYLOAD)));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.planLabel).toBe('max');
|
||||
expect(result.usage.windows['5h'].usedPercent).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps serving the last good values while Anthropic rate limits', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(PAYLOAD))
|
||||
.mockResolvedValueOnce(errorResponse(429, { 'retry-after': '120' }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await fetchQuota();
|
||||
const rateLimited = await fetchQuota();
|
||||
|
||||
expect(rateLimited.ok).toBe(true);
|
||||
expect(rateLimited.usage.windows['5h'].usedPercent).toBe(5);
|
||||
});
|
||||
|
||||
it('does not call Anthropic again while the rate-limit cooldown is active', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(PAYLOAD))
|
||||
.mockResolvedValueOnce(errorResponse(429));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await fetchQuota();
|
||||
await fetchQuota();
|
||||
await fetchQuota();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never shows one account cached values after the credential changes', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(PAYLOAD))
|
||||
.mockResolvedValueOnce(errorResponse(429));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await fetchQuota();
|
||||
credential.mockReturnValue({ ...CREDENTIAL, accessToken: 'access-b', refreshToken: 'refresh-b', planLabel: null });
|
||||
const afterSwitch = await fetchQuota();
|
||||
|
||||
expect(afterSwitch.ok).toBe(false);
|
||||
expect(afterSwitch.usage).toBeNull();
|
||||
});
|
||||
|
||||
it('explains an expired session instead of reporting a bare 401', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(errorResponse(401)));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toContain('Claude Code');
|
||||
});
|
||||
|
||||
it('surfaces a network failure instead of an empty successful result', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.usage).toBeNull();
|
||||
expect(result.error).toBe('socket hang up');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Shapes Anthropic's OAuth usage payload into quota windows.
|
||||
*
|
||||
* The payload carries the same limit twice: a legacy set of named fields
|
||||
* (`five_hour`, `seven_day`, ...) and a newer `limits` array. Only the array
|
||||
* reports model-scoped limits, and Anthropic ships new limit kinds there under
|
||||
* rotating internal code names, so limits are read from the array by `kind` and
|
||||
* fall back to the legacy fields when the array is missing.
|
||||
*
|
||||
* @module quota/providers/claude/transforms
|
||||
*/
|
||||
|
||||
import { asObject, asNonEmptyString, toNumber, toTimestamp, toUsageWindow, formatMoney } from '../../utils/index.js';
|
||||
|
||||
const SESSION_WINDOW = '5h';
|
||||
const WEEKLY_WINDOW = '7d';
|
||||
const EXTRA_USAGE_WINDOW = 'extra_usage';
|
||||
|
||||
const asArray = (value) => (Array.isArray(value) ? value : []);
|
||||
|
||||
/** Money in Anthropic's minor-unit form, e.g. `{ amount_minor: 10000, exponent: 2 }`. */
|
||||
const toAmount = (value) => {
|
||||
const money = asObject(value);
|
||||
const minor = toNumber(money?.amount_minor);
|
||||
if (minor === null) return null;
|
||||
const exponent = toNumber(money?.exponent) ?? 2;
|
||||
return minor / 10 ** exponent;
|
||||
};
|
||||
|
||||
const formatSpendLabel = (used, limit, currency) => {
|
||||
const usedLabel = formatMoney(used);
|
||||
if (usedLabel === null) return null;
|
||||
const prefix = currency === 'USD' || !currency ? '$' : `${currency} `;
|
||||
const limitLabel = formatMoney(limit);
|
||||
return limitLabel === null ? `${prefix}${usedLabel}` : `${prefix}${usedLabel} / ${prefix}${limitLabel}`;
|
||||
};
|
||||
|
||||
const addWindow = (target, key, { percent, resetAt, valueLabel }) => {
|
||||
if (percent === null && !valueLabel) return;
|
||||
target[key] = toUsageWindow({ usedPercent: percent, windowSeconds: null, resetAt, valueLabel });
|
||||
};
|
||||
|
||||
const applyLimitsArray = (limits, windows, models) => {
|
||||
for (const entry of limits) {
|
||||
const limit = asObject(entry);
|
||||
if (!limit) continue;
|
||||
const percent = toNumber(limit.percent);
|
||||
const resetAt = toTimestamp(limit.resets_at);
|
||||
const modelName = asNonEmptyString(asObject(asObject(limit.scope)?.model)?.display_name);
|
||||
|
||||
if (limit.kind === 'session') {
|
||||
addWindow(windows, SESSION_WINDOW, { percent, resetAt });
|
||||
continue;
|
||||
}
|
||||
if (limit.kind === 'weekly_all') {
|
||||
addWindow(windows, WEEKLY_WINDOW, { percent, resetAt });
|
||||
continue;
|
||||
}
|
||||
if (limit.kind === 'weekly_scoped' && modelName) {
|
||||
const modelWindows = {};
|
||||
addWindow(modelWindows, WEEKLY_WINDOW, { percent, resetAt });
|
||||
if (Object.keys(modelWindows).length > 0) models[modelName] = { windows: modelWindows };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyLegacyFields = (payload, windows) => {
|
||||
const fiveHour = asObject(payload.five_hour);
|
||||
const sevenDay = asObject(payload.seven_day);
|
||||
if (fiveHour) {
|
||||
addWindow(windows, SESSION_WINDOW, {
|
||||
percent: toNumber(fiveHour.utilization),
|
||||
resetAt: toTimestamp(fiveHour.resets_at)
|
||||
});
|
||||
}
|
||||
if (sevenDay) {
|
||||
addWindow(windows, WEEKLY_WINDOW, {
|
||||
percent: toNumber(sevenDay.utilization),
|
||||
resetAt: toTimestamp(sevenDay.resets_at)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra usage is the paid overflow beyond plan limits. It is only meaningful
|
||||
* once the account has it enabled; a disabled block would render as a permanent
|
||||
* empty bar.
|
||||
*/
|
||||
const applyExtraUsage = (payload, windows) => {
|
||||
const spend = asObject(payload.spend);
|
||||
if (!spend || spend.enabled !== true) return;
|
||||
const used = toAmount(spend.used);
|
||||
const limit = toAmount(spend.limit);
|
||||
addWindow(windows, EXTRA_USAGE_WINDOW, {
|
||||
percent: toNumber(spend.percent),
|
||||
resetAt: null,
|
||||
valueLabel: formatSpendLabel(used, limit, asNonEmptyString(asObject(spend.used)?.currency))
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {unknown} rawPayload Parsed JSON body of the OAuth usage endpoint.
|
||||
* @returns {{ windows: Record<string, object>, models: Record<string, object> }}
|
||||
*/
|
||||
export const toClaudeUsage = (rawPayload) => {
|
||||
const payload = asObject(rawPayload) ?? {};
|
||||
const windows = {};
|
||||
const models = {};
|
||||
|
||||
const limits = asArray(payload.limits);
|
||||
if (limits.length > 0) {
|
||||
applyLimitsArray(limits, windows, models);
|
||||
} else {
|
||||
applyLegacyFields(payload, windows);
|
||||
}
|
||||
|
||||
applyExtraUsage(payload, windows);
|
||||
|
||||
return { windows, models };
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { toClaudeUsage } from './transforms.js';
|
||||
|
||||
// Trimmed capture of GET https://api.anthropic.com/api/oauth/usage for a Max account.
|
||||
const LIVE_PAYLOAD = {
|
||||
five_hour: { utilization: 5.0, resets_at: '2026-08-14T19:10:00.313090+00:00' },
|
||||
seven_day: { utilization: 4.0, resets_at: '2026-08-20T15:00:00.313112+00:00' },
|
||||
seven_day_opus: null,
|
||||
seven_day_sonnet: null,
|
||||
nimbus_quill: { utilization: 0.0, resets_at: null },
|
||||
limits: [
|
||||
{ kind: 'session', group: 'session', percent: 5, resets_at: '2026-08-14T19:10:00.313090+00:00', scope: null, is_active: true },
|
||||
{ kind: 'weekly_all', group: 'weekly', percent: 4, resets_at: '2026-08-20T15:00:00.313112+00:00', scope: null, is_active: false },
|
||||
{
|
||||
kind: 'weekly_scoped',
|
||||
group: 'weekly',
|
||||
percent: 12,
|
||||
resets_at: '2026-08-20T15:00:00.313301+00:00',
|
||||
scope: { model: { id: null, display_name: 'Fable' }, surface: null },
|
||||
is_active: false
|
||||
}
|
||||
],
|
||||
spend: {
|
||||
used: { amount_minor: 250, currency: 'USD', exponent: 2 },
|
||||
limit: { amount_minor: 10000, currency: 'USD', exponent: 2 },
|
||||
percent: 2.5,
|
||||
enabled: true
|
||||
}
|
||||
};
|
||||
|
||||
describe('Claude usage transforms', () => {
|
||||
it('maps the limits array to session, weekly, and model-scoped windows', () => {
|
||||
const { windows, models } = toClaudeUsage(LIVE_PAYLOAD);
|
||||
|
||||
expect(windows['5h'].usedPercent).toBe(5);
|
||||
expect(windows['5h'].resetAt).toBe(Date.parse('2026-08-14T19:10:00.313090+00:00'));
|
||||
expect(windows['7d'].usedPercent).toBe(4);
|
||||
expect(models.Fable.windows['7d'].usedPercent).toBe(12);
|
||||
});
|
||||
|
||||
it('reports extra usage as a spend window with a money label', () => {
|
||||
const { windows } = toClaudeUsage(LIVE_PAYLOAD);
|
||||
|
||||
expect(windows.extra_usage.usedPercent).toBe(2.5);
|
||||
expect(windows.extra_usage.valueLabel).toBe('$2.50 / $100.00');
|
||||
});
|
||||
|
||||
it('omits extra usage when the account has it disabled', () => {
|
||||
const { windows } = toClaudeUsage({ ...LIVE_PAYLOAD, spend: { ...LIVE_PAYLOAD.spend, enabled: false } });
|
||||
|
||||
expect(windows.extra_usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the legacy named fields when no limits array is present', () => {
|
||||
const { windows, models } = toClaudeUsage({ five_hour: LIVE_PAYLOAD.five_hour, seven_day: LIVE_PAYLOAD.seven_day });
|
||||
|
||||
expect(windows['5h'].usedPercent).toBe(5);
|
||||
expect(windows['7d'].usedPercent).toBe(4);
|
||||
expect(models).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores unknown limit kinds instead of inventing windows for them', () => {
|
||||
const { windows } = toClaudeUsage({ limits: [{ kind: 'iguana_necktie', percent: 90, resets_at: null }] });
|
||||
|
||||
expect(windows).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty usage for a malformed payload', () => {
|
||||
expect(toClaudeUsage(null)).toEqual({ windows: {}, models: {} });
|
||||
expect(toClaudeUsage({ limits: 'nope' })).toEqual({ windows: {}, models: {} });
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { buildResult } from '../utils/index.js';
|
||||
|
||||
import * as claude from './claude.js';
|
||||
import * as claude from './claude/index.js';
|
||||
import * as codex from './codex.js';
|
||||
import * as copilot from './copilot.js';
|
||||
import * as crof from './crof.js';
|
||||
|
||||
@@ -53,13 +53,14 @@ export const toUsageWindow = ({ usedPercent, windowSeconds, resetAt, valueLabel
|
||||
};
|
||||
};
|
||||
|
||||
export const buildResult = ({ providerId, providerName, ok, configured, usage, error }) => ({
|
||||
export const buildResult = ({ providerId, providerName, ok, configured, usage, error, planLabel }) => ({
|
||||
providerId,
|
||||
providerName,
|
||||
ok,
|
||||
configured,
|
||||
usage: usage ?? null,
|
||||
...(error ? { error } : {}),
|
||||
...(planLabel ? { planLabel } : {}),
|
||||
fetchedAt: Date.now()
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user