Files
Bohdan Triapitsyn b77a30cd88 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.
2026-08-14 20:49:59 +03:00

114 lines
3.9 KiB
JavaScript

/**
* 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();