diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 20:40:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 20:40:35 +0900 |
| commit | 8151447758e6826a578363758a755c6cebd1c05f (patch) | |
| tree | 6afa780c28ca6e4622c1ab30238665caaad4371e /packages/core/src/credentials | |
| parent | f05099d450748cc7508f8cbde4e6539db2105f6d (diff) | |
| download | dispatch-8151447758e6826a578363758a755c6cebd1c05f.tar.gz dispatch-8151447758e6826a578363758a755c6cebd1c05f.zip | |
feat: claude max oauth support with multi-account switching, reasoning effort, and dynamic model listing
Diffstat (limited to 'packages/core/src/credentials')
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 467 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 18 |
2 files changed, 485 insertions, 0 deletions
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts new file mode 100644 index 0000000..87568e6 --- /dev/null +++ b/packages/core/src/credentials/claude.ts @@ -0,0 +1,467 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from "node:fs"; +import { dirname, join, basename } from "node:path"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; + +export interface ClaudeCredentials { + accessToken: string; + refreshToken: string; + expiresAt: number; + subscriptionType?: string; +} + +export interface ClaudeAccount { + id: string; + label: string; + source: string; + credentials: ClaudeCredentials; +} + +const OAUTH_TOKEN_URL = "https://claude.ai/v1/oauth/token"; +const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const CREDENTIAL_CACHE_TTL_MS = 30_000; + +const CREDENTIALS_DIR = join(homedir(), ".claude"); +const PRIMARY_CREDENTIALS_FILE = join(CREDENTIALS_DIR, ".credentials.json"); + +const accountCacheMap = new Map<string, { creds: ClaudeCredentials; cachedAt: number }>(); + +function parseCredentialsFile(raw: string): ClaudeCredentials | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + + const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed; + const creds = data as Record<string, unknown>; + + if ((creds as Record<string, unknown>).mcpOAuth && !(creds as Record<string, unknown>).accessToken) { + return null; + } + + if ( + typeof creds.accessToken !== "string" || + typeof creds.refreshToken !== "string" || + typeof creds.expiresAt !== "number" + ) { + return null; + } + + return { + accessToken: creds.accessToken as string, + refreshToken: creds.refreshToken as string, + expiresAt: creds.expiresAt as number, + subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined, + }; +} + +function readCredentialsFile(filePath: string): ClaudeCredentials | null { + try { + if (!existsSync(filePath)) return null; + const raw = readFileSync(filePath, "utf-8").trim(); + if (!raw) return null; + return parseCredentialsFile(raw); + } catch { + return null; + } +} + +function writeCredentialsFile(filePath: string, creds: ClaudeCredentials): void { + let existing: Record<string, unknown> = {}; + try { + if (existsSync(filePath)) { + const raw = readFileSync(filePath, "utf-8").trim(); + if (raw) { + existing = JSON.parse(raw); + } + } + } catch { + existing = {}; + } + + const hasWrapper = "claudeAiOauth" in existing; + const target = hasWrapper ? (existing.claudeAiOauth as Record<string, unknown>) : existing; + target.accessToken = creds.accessToken; + target.refreshToken = creds.refreshToken; + target.expiresAt = creds.expiresAt; + if (creds.subscriptionType) { + target.subscriptionType = creds.subscriptionType; + } + + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: "utf-8", mode: 0o600 }); + if (process.platform !== "win32") { + chmodSync(filePath, 0o600); + } +} + +async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials | null> { + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: OAUTH_CLIENT_ID, + refresh_token: refreshToken, + }); + + try { + const response = await fetch(OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + if (!response.ok) { + return null; + } + + const data = await response.json() as Record<string, unknown>; + if (!data.access_token || typeof data.access_token !== "string") { + return null; + } + + return { + accessToken: data.access_token as string, + refreshToken: (data.refresh_token as string) ?? refreshToken, + expiresAt: Date.now() + ((data.expires_in as number) ?? 36_000) * 1000, + subscriptionType: typeof data.subscriptionType === "string" ? data.subscriptionType : undefined, + }; + } catch { + return null; + } +} + +function buildAccountLabels(accounts: ClaudeAccount[]): void { + const counts = new Map<string, number>(); + for (const acct of accounts) { + const base = acct.credentials.subscriptionType + ? `Claude ${acct.credentials.subscriptionType.charAt(0).toUpperCase() + acct.credentials.subscriptionType.slice(1)}` + : "Claude"; + const count = (counts.get(base) ?? 0) + 1; + counts.set(base, count); + acct.label = count > 1 ? `${base} ${count}` : base; + } +} + +export function discoverClaudeAccounts(): ClaudeAccount[] { + const accounts: ClaudeAccount[] = []; + + if (!existsSync(CREDENTIALS_DIR)) { + return accounts; + } + + const primaryCreds = readCredentialsFile(PRIMARY_CREDENTIALS_FILE); + if (primaryCreds) { + accounts.push({ + id: "claude-default", + label: "", + source: PRIMARY_CREDENTIALS_FILE, + credentials: primaryCreds, + }); + } + + try { + const files = readdirSync(CREDENTIALS_DIR); + const credFiles = files.filter( + (f) => f.startsWith(".credentials") && f.endsWith(".json") && f !== ".credentials.json", + ); + for (const file of credFiles) { + const filePath = join(CREDENTIALS_DIR, file); + const creds = readCredentialsFile(filePath); + if (creds) { + const id = basename(file, ".json").replace(/^\.credentials/, "claude") || `claude-${file}`; + accounts.push({ + id, + label: "", + source: filePath, + credentials: creds, + }); + } + } + } catch { + // ignore + } + + buildAccountLabels(accounts); + return accounts; +} + +export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredentials | null { + const cached = accountCacheMap.get(account.id); + const now = Date.now(); + if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) { + return cached.creds; + } + + // Re-read from file to pick up external updates + const onDisk = readCredentialsFile(account.source); + if (onDisk) { + account.credentials = onDisk; + } + + if (account.credentials.expiresAt > now + 60_000) { + accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now }); + return account.credentials; + } + + // Try OAuth refresh + if (account.credentials.refreshToken) { + // Synchronous refresh not available in this context, but the async version will be used + // by getCredentialsForAccount below + return null; + } + + return null; +} + +export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Promise<ClaudeCredentials | null> { + const cached = accountCacheMap.get(account.id); + const now = Date.now(); + if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) { + return cached.creds; + } + + // Re-read from file + const onDisk = readCredentialsFile(account.source); + if (onDisk) { + account.credentials = onDisk; + } + + if (account.credentials.expiresAt > now + 60_000) { + accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now }); + return account.credentials; + } + + // Try OAuth refresh + if (account.credentials.refreshToken) { + const refreshed = await refreshViaOAuth(account.credentials.refreshToken); + if (refreshed && refreshed.expiresAt > now + 60_000) { + account.credentials = refreshed; + writeCredentialsFile(account.source, refreshed); + accountCacheMap.set(account.id, { creds: refreshed, cachedAt: now }); + return refreshed; + } + } + + return null; +} + +// ─── Billing Header Computation ──────────────────────────────── + +const BILLING_SALT = "59cf53e54c78"; +const CC_VERSION = "2.1.112"; + +function extractFirstUserMessageText(messages: Array<{ role: string; content: string }>): string { + const userMsg = messages.find((m) => m.role === "user"); + if (!userMsg) return ""; + if (typeof userMsg.content === "string") return userMsg.content; + return ""; +} + +function computeCch(messageText: string): string { + return createHash("sha256").update(messageText).digest("hex").slice(0, 5); +} + +function computeVersionSuffix(messageText: string, version: string): string { + const sampled = [4, 7, 20] + .map((i) => (i < messageText.length ? messageText[i] : "0")) + .join(""); + const input = `${BILLING_SALT}${sampled}${version}`; + return createHash("sha256").update(input).digest("hex").slice(0, 3); +} + +export function buildBillingHeaderValue(messages: Array<{ role: string; content: string }>): string { + const text = extractFirstUserMessageText(messages); + const version = process.env.ANTHROPIC_CLI_VERSION ?? CC_VERSION; + const suffix = computeVersionSuffix(text, version); + const cch = computeCch(text); + return `x-anthropic-billing-header: cc_version=${version}.${suffix}; cc_entrypoint=sdk-cli; cch=${cch};`; +} + +export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; + +// ─── Anthropic Beta Headers ─────────────────────────────────── + +const BASE_BETAS = [ + "claude-code-20250219", + "oauth-2025-04-20", + "interleaved-thinking-2025-05-14", + "prompt-caching-scope-2026-01-05", + "context-management-2025-06-27", + "advisor-tool-2026-03-01", +]; + +export function getAnthropicBetas(): string[] { + return [...BASE_BETAS]; +} + +// ─── Anthropic Request Headers ──────────────────────────────── + +export function getAnthropicHeaders(accessToken: string): Record<string, string> { + return { + authorization: `Bearer ${accessToken}`, + "anthropic-version": "2023-06-01", + "anthropic-beta": getAnthropicBetas().join(","), + "anthropic-dangerous-direct-browser-access": "true", + "x-app": "cli", + "user-agent": `claude-cli/${CC_VERSION} (external, sdk-cli)`, + }; +} + +// ─── Usage Tracking ─────────────────────────────────────────── + +export interface ClaudeUsageBucket { + utilization?: number; + resetsAt?: number; +} + +export interface ClaudeUsageReport { + fiveHour?: ClaudeUsageBucket; + sevenDay?: ClaudeUsageBucket; + sevenDayOpus?: ClaudeUsageBucket; + sevenDaySonnet?: ClaudeUsageBucket; + accountId?: string; + email?: string; + orgId?: string; +} + +// ─── Well-known Anthropic models ────────────────────────────── + +/** + * Fetch the live list of available models from Anthropic's /v1/models endpoint. + * Requires valid OAuth credentials with anthropic-beta headers. + */ +export async function fetchAnthropicModels(accessToken: string): Promise<string[]> { + const headers: Record<string, string> = { + ...getAnthropicHeaders(accessToken), + accept: "application/json", + }; + + try { + const response = await fetch("https://api.anthropic.com/v1/models", { headers }); + if (!response.ok) { + console.warn(`dispatch: Anthropic /v1/models returned ${response.status}`); + return []; + } + + const data = (await response.json()) as { data?: Array<{ id: string }>; models?: Array<{ id: string }> }; + const entries = data.data ?? data.models ?? []; + return entries.map((m) => m.id).filter(Boolean); + } catch (err) { + console.warn(`dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`); + return []; + } +} + +/** Fallback list if /v1/models is unreachable. */ +export const ANTHROPIC_MODELS_FALLBACK = [ + "claude-sonnet-4-20250514", + "claude-opus-4-20250514", + "claude-3.5-sonnet-20241022", + "claude-3.5-haiku-20241022", + "claude-3-opus-20240229", +]; + +// ─── Credential Validation ──────────────────────────────────── + +export interface ClaudeProfile { + accountId?: string; + email?: string; + subscriptionType?: string; +} + +/** + * Validate that Claude credentials are usable by hitting the OAuth profile endpoint. + * Returns the profile info if valid, or null if the token is dead. + */ +export async function validateAccountCredentials(account: ClaudeAccount): Promise<ClaudeProfile | null> { + const creds = await refreshAccountCredentialsAsync(account); + if (!creds) return null; + + const url = "https://api.anthropic.com/api/oauth/profile"; + const headers: Record<string, string> = { + ...getAnthropicHeaders(creds.accessToken), + accept: "application/json, text/plain, */*", + }; + + try { + const response = await fetch(url, { headers }); + if (!response.ok) return null; + + const data = (await response.json()) as Record<string, unknown>; + + const profile: ClaudeProfile = {}; + const uuid = typeof data.uuid === "string" ? data.uuid : undefined; + const email = typeof data.email === "string" ? data.email : undefined; + + if (uuid) profile.accountId = uuid; + if (email) profile.email = email; + + // subscriptionType comes from the credentials file, but profile may also carry it + profile.subscriptionType = account.credentials.subscriptionType; + + return profile; + } catch { + return null; + } +} + +async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport | null> { + const url = "https://api.anthropic.com/api/oauth/usage"; + const headers: Record<string, string> = { + ...getAnthropicHeaders(accessToken), + accept: "application/json, text/plain, */*", + "content-type": "application/json", + }; + + try { + const response = await fetch(url, { headers }); + if (!response.ok) return null; + + const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined; + const data = (await response.json()) as Record<string, unknown>; + + const parseBucket = (bucket: unknown): ClaudeUsageBucket | undefined => { + if (!bucket || typeof bucket !== "object" || Array.isArray(bucket)) return undefined; + const b = bucket as Record<string, unknown>; + const utilization = typeof b.utilization === "number" ? b.utilization : undefined; + const resetsAt = typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined; + if (utilization === undefined && resetsAt === undefined) return undefined; + return { utilization, resetsAt }; + }; + + const report: ClaudeUsageReport = { + fiveHour: parseBucket(data.five_hour), + sevenDay: parseBucket(data.seven_day), + sevenDayOpus: parseBucket(data.seven_day_opus), + sevenDaySonnet: parseBucket(data.seven_day_sonnet), + }; + + if (orgId) report.orgId = orgId; + + // Try to extract identity + const accountId = + typeof data.account_id === "string" ? data.account_id : + typeof data.user_id === "string" ? data.user_id : + typeof data.org_id === "string" ? data.org_id : undefined; + if (accountId) report.accountId = accountId; + + const email = typeof data.email === "string" ? data.email : undefined; + if (email) report.email = email; + + return report; + } catch { + return null; + } +} + +export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> { + const creds = await refreshAccountCredentialsAsync(account); + if (!creds) return null; + return fetchClaudeUsage(creds.accessToken); +}
\ No newline at end of file diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts new file mode 100644 index 0000000..d72ad48 --- /dev/null +++ b/packages/core/src/credentials/index.ts @@ -0,0 +1,18 @@ +export { + type ClaudeCredentials, + type ClaudeAccount, + type ClaudeUsageBucket, + type ClaudeUsageReport, + type ClaudeProfile, + ANTHROPIC_MODELS_FALLBACK, + fetchAnthropicModels, + discoverClaudeAccounts, + refreshAccountCredentials, + refreshAccountCredentialsAsync, + validateAccountCredentials, + buildBillingHeaderValue, + getAnthropicBetas, + getAnthropicHeaders, + getAccountUsage, + SYSTEM_IDENTITY, +} from "./claude.js";
\ No newline at end of file |
