Refactor application architecture and shared functionality
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Claude subscription quota.
|
||||
*
|
||||
* Reports the plan limits Claude Code itself is bound by (rolling session
|
||||
* window, weekly windows, model-scoped weekly windows, and paid extra usage)
|
||||
* using the OAuth credential Claude Code already holds.
|
||||
*
|
||||
* @module quota/providers/claude
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { buildResult } from '../../utils/index.js';
|
||||
import { loadClaudeCredential } from './auth.js';
|
||||
import { toClaudeUsage } from './transforms.js';
|
||||
|
||||
export const providerId = 'claude';
|
||||
export const providerName = 'Claude';
|
||||
export const aliases = ['anthropic', 'claude'];
|
||||
|
||||
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
||||
const OAUTH_BETA_HEADER = 'oauth-2025-04-20';
|
||||
const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000;
|
||||
const MAX_COOLDOWN_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Last good payload, kept only to survive Anthropic's aggressive rate limiting.
|
||||
* Keyed by credential fingerprint so a second account never sees the first
|
||||
* account's numbers.
|
||||
*
|
||||
* @type {{ fingerprint: string, usage: object, planLabel: string|null }|null}
|
||||
*/
|
||||
let cachedUsage = null;
|
||||
let cooldownUntil = 0;
|
||||
let pendingFetch = null;
|
||||
|
||||
const fingerprintOf = (credential) =>
|
||||
createHash('sha256').update(`${credential.accessToken}\0${credential.refreshToken ?? ''}`).digest('hex');
|
||||
|
||||
const cooldownFromHeader = (response) => {
|
||||
const raw = response.headers.get('retry-after');
|
||||
const retryAfter = Number(raw);
|
||||
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
||||
return Math.min(retryAfter * 1000, MAX_COOLDOWN_MS);
|
||||
}
|
||||
const retryAt = raw ? Date.parse(raw) : Number.NaN;
|
||||
if (Number.isFinite(retryAt) && retryAt > Date.now()) {
|
||||
return Math.min(retryAt - Date.now(), MAX_COOLDOWN_MS);
|
||||
}
|
||||
return DEFAULT_COOLDOWN_MS;
|
||||
};
|
||||
|
||||
const cachedResultFor = (fingerprint, planLabel) => {
|
||||
if (!cachedUsage || cachedUsage.fingerprint !== fingerprint) return null;
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: cachedUsage.usage,
|
||||
planLabel: planLabel ?? cachedUsage.planLabel
|
||||
});
|
||||
};
|
||||
|
||||
const failure = (error, { configured = true } = {}) =>
|
||||
buildResult({ providerId, providerName, ok: false, configured, error });
|
||||
|
||||
export const isConfigured = () => Boolean(loadClaudeCredential());
|
||||
|
||||
const fetchQuotaUncoalesced = async () => {
|
||||
const credential = loadClaudeCredential();
|
||||
if (!credential) {
|
||||
return failure('Not configured', { configured: false });
|
||||
}
|
||||
|
||||
const fingerprint = fingerprintOf(credential);
|
||||
if (cachedUsage && cachedUsage.fingerprint !== fingerprint) {
|
||||
cachedUsage = null;
|
||||
cooldownUntil = 0;
|
||||
}
|
||||
|
||||
if (Date.now() < cooldownUntil) {
|
||||
return cachedResultFor(fingerprint, credential.planLabel)
|
||||
?? failure('Rate limited. Retrying soon.');
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(USAGE_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${credential.accessToken}`,
|
||||
'anthropic-beta': OAUTH_BETA_HEADER
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return failure(error instanceof Error ? error.message : 'Request failed');
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
cooldownUntil = Date.now() + cooldownFromHeader(response);
|
||||
return cachedResultFor(fingerprint, credential.planLabel)
|
||||
?? failure('Rate limited. Retrying soon.');
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return failure('Claude session expired. Open Claude Code to sign in again.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return failure(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return failure('Unexpected response from Anthropic');
|
||||
}
|
||||
|
||||
const { windows, models } = toClaudeUsage(payload);
|
||||
const usage = Object.keys(models).length > 0 ? { windows, models } : { windows };
|
||||
cachedUsage = { fingerprint, usage, planLabel: credential.planLabel };
|
||||
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage,
|
||||
planLabel: credential.planLabel
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchQuota = () => {
|
||||
if (pendingFetch) return pendingFetch;
|
||||
|
||||
const request = fetchQuotaUncoalesced();
|
||||
const pending = request.finally(() => {
|
||||
if (pendingFetch === pending) pendingFetch = null;
|
||||
});
|
||||
pendingFetch = pending;
|
||||
return pendingFetch;
|
||||
};
|
||||
|
||||
/** Test seam: clears the rate-limit cache between cases. */
|
||||
export const resetClaudeQuotaCache = () => {
|
||||
cachedUsage = null;
|
||||
cooldownUntil = 0;
|
||||
pendingFetch = null;
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
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('coalesces concurrent refreshes into one Anthropic request', async () => {
|
||||
let resolveResponse;
|
||||
const fetchMock = vi.fn().mockReturnValue(new Promise((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const first = fetchQuota();
|
||||
const second = fetchQuota();
|
||||
resolveResponse(jsonResponse(PAYLOAD));
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstResult.ok).toBe(true);
|
||||
expect(secondResult.ok).toBe(true);
|
||||
});
|
||||
|
||||
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,127 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// Consumers rank limits by how soon they run out, so each window carries its
|
||||
// duration. Extra usage is a monthly spend cap, not a rolling window.
|
||||
const SESSION_WINDOW_SECONDS = 5 * 60 * 60;
|
||||
const WEEKLY_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
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, windowSeconds = null }) => {
|
||||
if (percent === null && !valueLabel) return;
|
||||
target[key] = toUsageWindow({ usedPercent: percent, windowSeconds, 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, windowSeconds: SESSION_WINDOW_SECONDS });
|
||||
continue;
|
||||
}
|
||||
if (limit.kind === 'weekly_all') {
|
||||
addWindow(windows, WEEKLY_WINDOW, { percent, resetAt, windowSeconds: WEEKLY_WINDOW_SECONDS });
|
||||
continue;
|
||||
}
|
||||
if (limit.kind === 'weekly_scoped' && modelName) {
|
||||
const modelWindows = {};
|
||||
addWindow(modelWindows, WEEKLY_WINDOW, { percent, resetAt, windowSeconds: WEEKLY_WINDOW_SECONDS });
|
||||
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),
|
||||
windowSeconds: SESSION_WINDOW_SECONDS
|
||||
});
|
||||
}
|
||||
if (sevenDay) {
|
||||
addWindow(windows, WEEKLY_WINDOW, {
|
||||
percent: toNumber(sevenDay.utilization),
|
||||
resetAt: toTimestamp(sevenDay.resets_at),
|
||||
windowSeconds: WEEKLY_WINDOW_SECONDS
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,82 @@
|
||||
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 each window duration so callers can rank limits by urgency', () => {
|
||||
const { windows, models } = toClaudeUsage(LIVE_PAYLOAD);
|
||||
|
||||
expect(windows['5h'].windowSeconds).toBe(5 * 60 * 60);
|
||||
expect(windows['7d'].windowSeconds).toBe(7 * 24 * 60 * 60);
|
||||
expect(models.Fable.windows['7d'].windowSeconds).toBe(7 * 24 * 60 * 60);
|
||||
expect(windows.extra_usage.windowSeconds).toBeNull();
|
||||
});
|
||||
|
||||
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: {} });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import { asObject, buildResult, getAuthEntry, normalizeAuthEntry, toNumber, toUsageWindow } from '../utils/index.js';
|
||||
|
||||
export const providerId = 'command-code';
|
||||
export const providerName = 'Command Code';
|
||||
export const aliases = ['command-code'];
|
||||
|
||||
const API_BASE_URL = 'https://api.commandcode.ai';
|
||||
|
||||
const getApiKey = (auth = readAuthFile()) => {
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
const stored = entry?.key ?? entry?.access ?? entry?.token;
|
||||
return (typeof stored === 'string' ? stored.trim() : '') || process.env.COMMAND_CODE_API_KEY?.trim() || null;
|
||||
};
|
||||
|
||||
const requestJson = async (path, apiKey, fetchImpl) => {
|
||||
const response = await fetchImpl(`${API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'User-Agent': 'OpenChamber quota provider',
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) throw new Error('Command Code authentication failed');
|
||||
if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`);
|
||||
return response.json().catch(() => null);
|
||||
};
|
||||
|
||||
const formatCredits = (value) => String(Math.round((value + Number.EPSILON) * 100) / 100);
|
||||
|
||||
const toBalanceWindow = (value) => toUsageWindow({
|
||||
usedPercent: null,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: formatCredits(value),
|
||||
});
|
||||
|
||||
export const parseCommandCodeCredits = (payload) => {
|
||||
const root = asObject(payload);
|
||||
const credits = asObject(root?.credits);
|
||||
const limits = asObject(root?.windowLimits);
|
||||
const windows = {};
|
||||
|
||||
for (const [label, field] of [['monthly_credits', 'monthlyCredits'], ['purchased_credits', 'purchasedCredits'], ['free_credits', 'freeCredits']]) {
|
||||
const value = toNumber(credits?.[field]);
|
||||
if (value !== null) windows[label] = toBalanceWindow(value);
|
||||
}
|
||||
|
||||
for (const [label, field, windowSeconds] of [['5h', 'fiveHour', 5 * 60 * 60], ['weekly', 'weekly', 7 * 24 * 60 * 60]]) {
|
||||
const limit = asObject(limits?.[field]);
|
||||
const used = toNumber(limit?.used);
|
||||
const cap = toNumber(limit?.cap);
|
||||
if (used === null || cap === null || cap <= 0) continue;
|
||||
const resetAt = toNumber(limit?.resetAt);
|
||||
windows[label] = toUsageWindow({
|
||||
usedPercent: Math.min(100, Math.max(0, used / cap * 100)),
|
||||
windowSeconds,
|
||||
resetAt: resetAt === null ? null : resetAt < 1_000_000_000_000 ? resetAt * 1000 : resetAt,
|
||||
valueLabel: `${formatCredits(used)} / ${formatCredits(cap)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return windows;
|
||||
};
|
||||
|
||||
export const fetchCommandCodeUsage = async (apiKey, fetchImpl = fetch) => {
|
||||
const identity = asObject(await requestJson('/alpha/whoami', apiKey, fetchImpl));
|
||||
const org = asObject(identity?.org);
|
||||
const orgId = typeof org?.id === 'string' ? org.id.trim() : '';
|
||||
const creditsPath = orgId
|
||||
? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}`
|
||||
: '/alpha/billing/credits';
|
||||
const credits = await requestJson(creditsPath, apiKey, fetchImpl);
|
||||
const windows = parseCommandCodeCredits(credits);
|
||||
if (Object.keys(windows).length === 0) throw new Error('Command Code usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
|
||||
export const isConfigured = () => Boolean(getApiKey());
|
||||
|
||||
export const fetchQuota = async (auth = readAuthFile()) => {
|
||||
const apiKey = getApiKey(auth);
|
||||
if (!apiKey) return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' });
|
||||
try {
|
||||
return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows: await fetchCommandCodeUsage(apiKey) } });
|
||||
} catch (error) {
|
||||
return buildResult({ providerId, providerName, ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fetchCommandCodeUsage, fetchQuota, parseCommandCodeCredits } from './command-code.js';
|
||||
|
||||
const creditsPayload = {
|
||||
credits: { monthlyCredits: 120, purchasedCredits: 30, freeCredits: 5 },
|
||||
windowLimits: {
|
||||
fiveHour: { used: 25, cap: 100, resetAt: 1_776_000_000 },
|
||||
weekly: { used: 70, cap: 200, resetAt: 1_776_604_800 },
|
||||
},
|
||||
};
|
||||
|
||||
describe('Command Code quota provider', () => {
|
||||
it('parses balances and rate-limit windows', () => {
|
||||
const windows = parseCommandCodeCredits(creditsPayload);
|
||||
expect(windows.monthly_credits).toMatchObject({ usedPercent: null, valueLabel: '120' });
|
||||
expect(windows.purchased_credits).toMatchObject({ usedPercent: null, valueLabel: '30' });
|
||||
expect(windows.free_credits).toMatchObject({ usedPercent: null, valueLabel: '5' });
|
||||
expect(windows['5h']).toMatchObject({ usedPercent: 25, valueLabel: '25 / 100', resetAt: 1_776_000_000_000 });
|
||||
expect(windows.weekly.usedPercent).toBe(35);
|
||||
});
|
||||
|
||||
it('formats fractional credit values for display', () => {
|
||||
const windows = parseCommandCodeCredits({
|
||||
credits: { monthlyCredits: 69.7947070034 },
|
||||
windowLimits: { fiveHour: { used: 0.2052929966, cap: 14 } },
|
||||
});
|
||||
expect(windows.monthly_credits.valueLabel).toBe('69.79');
|
||||
expect(windows['5h'].valueLabel).toBe('0.21 / 14');
|
||||
});
|
||||
|
||||
it('resolves the organization before fetching credits', async () => {
|
||||
const requests = [];
|
||||
const windows = await fetchCommandCodeUsage('secret', async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
return new Response(JSON.stringify(url.endsWith('/alpha/whoami') ? { org: { id: 'org/a' } } : creditsPayload));
|
||||
});
|
||||
expect(requests.map(({ url }) => url)).toEqual([
|
||||
'https://api.commandcode.ai/alpha/whoami',
|
||||
'https://api.commandcode.ai/alpha/billing/credits?orgId=org%2Fa',
|
||||
]);
|
||||
expect(requests[0].options.headers.Authorization).toBe('Bearer secret');
|
||||
expect(windows['5h'].usedPercent).toBe(25);
|
||||
});
|
||||
|
||||
it('fetches account-scoped credits without orgId for personal accounts', async () => {
|
||||
const urls = [];
|
||||
await fetchCommandCodeUsage('secret', async (url) => {
|
||||
urls.push(url);
|
||||
return new Response(JSON.stringify(url.endsWith('/alpha/whoami') ? { user: { id: 'user-1' }, org: null } : creditsPayload));
|
||||
});
|
||||
expect(urls).toEqual([
|
||||
'https://api.commandcode.ai/alpha/whoami',
|
||||
'https://api.commandcode.ai/alpha/billing/credits',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not expose credentials in authentication errors', async () => {
|
||||
await expect(fetchCommandCodeUsage('secret', async () => new Response('', { status: 401 }))).rejects.toThrow('authentication failed');
|
||||
});
|
||||
|
||||
it('reads OAuth access credentials from the OpenCode auth file', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ org: { id: 'org-1' } })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(creditsPayload)));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await fetchQuota({ 'command-code': { type: 'oauth', access: 'test-token' } });
|
||||
expect(result).toMatchObject({ providerId: 'command-code', ok: true, configured: true });
|
||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-token');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
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 commandCode from './command-code.js';
|
||||
import * as copilot from './copilot.js';
|
||||
import * as crof from './crof.js';
|
||||
import * as cursor from './cursor.js';
|
||||
@@ -29,6 +30,12 @@ import * as opencodeGo from './opencode-go.js';
|
||||
import * as xai from './xai.js';
|
||||
|
||||
const registry = {
|
||||
'command-code': {
|
||||
providerId: commandCode.providerId,
|
||||
providerName: commandCode.providerName,
|
||||
isConfigured: commandCode.isConfigured,
|
||||
fetchQuota: commandCode.fetchQuota
|
||||
},
|
||||
claude: {
|
||||
providerId: claude.providerId,
|
||||
providerName: claude.providerName,
|
||||
@@ -151,6 +158,8 @@ const registry = {
|
||||
}
|
||||
};
|
||||
|
||||
const pendingFetches = new Map();
|
||||
|
||||
export const listConfiguredQuotaProviders = () => {
|
||||
const configured = [];
|
||||
|
||||
@@ -167,7 +176,7 @@ export const listConfiguredQuotaProviders = () => {
|
||||
return configured;
|
||||
};
|
||||
|
||||
export const fetchQuotaForProvider = async (providerId) => {
|
||||
const fetchQuotaForProviderUncoalesced = async (providerId) => {
|
||||
const provider = registry[providerId];
|
||||
|
||||
if (!provider) {
|
||||
@@ -193,6 +202,17 @@ export const fetchQuotaForProvider = async (providerId) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchQuotaForProvider = (providerId) => {
|
||||
const existing = pendingFetches.get(providerId);
|
||||
if (existing) return existing;
|
||||
|
||||
const pending = fetchQuotaForProviderUncoalesced(providerId).finally(() => {
|
||||
if (pendingFetches.get(providerId) === pending) pendingFetches.delete(providerId);
|
||||
});
|
||||
pendingFetches.set(providerId, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
export const fetchClaudeQuota = claude.fetchQuota;
|
||||
export const fetchOpenaiQuota = openai.fetchQuota;
|
||||
export const fetchGoogleQuota = google.fetchGoogleQuota;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import * as google from './google/index.js';
|
||||
import { listConfiguredQuotaProviders } from './index.js';
|
||||
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './index.js';
|
||||
|
||||
describe('quota provider registry', () => {
|
||||
it('exposes google provider configuration helpers through the provider module', () => {
|
||||
@@ -14,4 +14,13 @@ describe('quota provider registry', () => {
|
||||
it('can list configured providers without missing provider exports', () => {
|
||||
expect(() => listConfiguredQuotaProviders()).not.toThrow();
|
||||
});
|
||||
|
||||
it('coalesces concurrent refreshes by provider ID', async () => {
|
||||
const first = fetchQuotaForProvider('unsupported-test-provider');
|
||||
const second = fetchQuotaForProvider('unsupported-test-provider');
|
||||
|
||||
expect(first).toBe(second);
|
||||
await first;
|
||||
expect(fetchQuotaForProvider('unsupported-test-provider')).not.toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
resolveWindowSeconds,
|
||||
resolveWindowLabel,
|
||||
normalizeTimestamp
|
||||
@@ -13,6 +14,20 @@ export const providerId = 'zai-coding-plan';
|
||||
export const providerName = 'z.ai';
|
||||
const aliases = ['zai-coding-plan', 'zai', 'z.ai'];
|
||||
|
||||
// CREDIT_LIMIT entries carry `usage` (total credits), `currentValue` (consumed),
|
||||
// and `remaining`; TOKENS_LIMIT entries only carry a percentage.
|
||||
const formatCreditAmount = (value) => {
|
||||
if (value < 1000) return value.toLocaleString('en-US');
|
||||
return `${Math.round(value / 100) / 10}k`;
|
||||
};
|
||||
|
||||
const formatCreditValueLabel = (limit) => {
|
||||
const used = toNumber(limit?.currentValue);
|
||||
const total = toNumber(limit?.usage);
|
||||
if (used === null || total === null) return null;
|
||||
return `${formatCreditAmount(used)} / ${formatCreditAmount(total)} credits`;
|
||||
};
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
@@ -56,16 +71,20 @@ export const fetchQuota = async () => {
|
||||
const payload = await response.json();
|
||||
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
|
||||
const windows = {};
|
||||
for (const tokensLimit of limits.filter((limit) => limit?.type === 'TOKENS_LIMIT')) {
|
||||
const windowSeconds = resolveWindowSeconds(tokensLimit);
|
||||
// The API renamed TOKENS_LIMIT to CREDIT_LIMIT; field semantics stayed the same,
|
||||
// so both limit types map to the same windows.
|
||||
for (const limit of limits.filter((entry) => entry?.type === 'TOKENS_LIMIT' || entry?.type === 'CREDIT_LIMIT')) {
|
||||
const windowSeconds = resolveWindowSeconds(limit);
|
||||
const windowLabel = resolveWindowLabel(windowSeconds);
|
||||
const resetAt = tokensLimit?.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null;
|
||||
const usedPercent = typeof tokensLimit?.percentage === 'number' ? tokensLimit.percentage : null;
|
||||
const resetAt = limit?.nextResetTime ? normalizeTimestamp(limit.nextResetTime) : null;
|
||||
const usedPercent = typeof limit?.percentage === 'number' ? limit.percentage : null;
|
||||
const creditValueLabel = formatCreditValueLabel(limit);
|
||||
|
||||
windows[windowLabel] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
resetAt
|
||||
resetAt,
|
||||
valueLabel: creditValueLabel
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,7 +102,8 @@ export const fetchQuota = async () => {
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows }
|
||||
usage: { windows },
|
||||
planLabel: typeof payload?.data?.level === 'string' && payload.data.level ? payload.data.level : null
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
|
||||
@@ -51,4 +51,37 @@ describe('Z.ai quota provider', () => {
|
||||
resetAt: 1787128459979,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps CREDIT_LIMIT entries to windows with credit value labels and plan level', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
code: 200,
|
||||
data: {
|
||||
limits: [
|
||||
{ type: 'CREDIT_LIMIT', unit: 3, number: 5, usage: 12000, currentValue: 65, remaining: 11934, percentage: 1, nextResetTime: 1787257978907 },
|
||||
{ type: 'CREDIT_LIMIT', unit: 6, number: 1, usage: 60000, currentValue: 65, remaining: 59934, percentage: 1, nextResetTime: 1787844668997 },
|
||||
],
|
||||
level: 'pro',
|
||||
},
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
const windows = result.usage.windows;
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.planLabel).toBe('pro');
|
||||
expect(windows['5h']).toMatchObject({
|
||||
usedPercent: 1,
|
||||
remainingPercent: 99,
|
||||
windowSeconds: 5 * 60 * 60,
|
||||
resetAt: 1787257978907,
|
||||
valueLabel: '65 / 12k credits',
|
||||
});
|
||||
expect(windows.weekly).toMatchObject({
|
||||
usedPercent: 1,
|
||||
remainingPercent: 99,
|
||||
windowSeconds: 7 * 24 * 60 * 60,
|
||||
resetAt: 1787844668997,
|
||||
valueLabel: '65 / 60k credits',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user