diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/credentials | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/src/credentials')
| -rw-r--r-- | packages/core/src/credentials/anthropic-betas.ts | 24 | ||||
| -rw-r--r-- | packages/core/src/credentials/api-keys.ts | 80 | ||||
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 694 | ||||
| -rw-r--r-- | packages/core/src/credentials/copilot.ts | 64 | ||||
| -rw-r--r-- | packages/core/src/credentials/google.ts | 178 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 52 | ||||
| -rw-r--r-- | packages/core/src/credentials/opencode.ts | 126 | ||||
| -rw-r--r-- | packages/core/src/credentials/store.ts | 185 |
8 files changed, 0 insertions, 1403 deletions
diff --git a/packages/core/src/credentials/anthropic-betas.ts b/packages/core/src/credentials/anthropic-betas.ts deleted file mode 100644 index 802ebbd..0000000 --- a/packages/core/src/credentials/anthropic-betas.ts +++ /dev/null @@ -1,24 +0,0 @@ -// ─── Anthropic Beta Headers ─────────────────────────────────── -// -// The set of `anthropic-beta` features the official Claude Code CLI sends on -// every request. Kept in a dependency-free module (no DB / `bun:sqlite` -// import) so the LLM provider layer (`llm/provider.ts`) can pull the list in -// without dragging the whole credentials/DB stack into its import graph. -// -// `prompt-caching-scope-2026-01-05` is load-bearing for cost: without it the -// Anthropic API silently ignores every `cache_control` breakpoint we place, -// producing a 0% cache hit rate (see notes/claude-report.md). `oauth-2025-04-20` -// gates the Bearer/OAuth flow used by Claude Pro/Max subscriptions. - -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]; -} diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts deleted file mode 100644 index ef30400..0000000 --- a/packages/core/src/credentials/api-keys.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { getDatabase } from "../db/index.js"; - -export interface StoredApiKey { - keyId: string; - provider: string; - apiKey: string; - importedAt: number; - updatedAt: number; -} - -/** - * Store or update an API key in the database. - */ -export function setApiKey(keyId: string, provider: string, apiKey: string): void { - const db = getDatabase(); - const now = Date.now(); - db.query( - `INSERT INTO api_keys (key_id, provider, api_key, imported_at, updated_at) - VALUES ($keyId, $provider, $apiKey, $now, $now) - ON CONFLICT(key_id) DO UPDATE SET - api_key = $apiKey, - updated_at = $now`, - ).run({ - $keyId: keyId, - $provider: provider, - $apiKey: apiKey, - $now: now, - }); -} - -/** - * Get a stored API key by key ID. Returns the key string or null. - */ -export function getApiKey(keyId: string): string | null { - const db = getDatabase(); - const row = db - .query("SELECT api_key FROM api_keys WHERE key_id = $keyId") - .get({ $keyId: keyId }) as { api_key: string } | null; - return row?.api_key ?? null; -} - -/** - * Resolve an API key from the database, with env var fallback. - * Pass the env var name (e.g. "GOOGLE_API_KEY") to check process.env as well. - */ -export function resolveApiKey(keyId: string, envVar?: string): string | null { - const dbKey = getApiKey(keyId); - if (dbKey) return dbKey; - if (envVar) return process.env[envVar] ?? null; - return null; -} - -/** - * Delete a stored API key. - */ -export function deleteApiKey(keyId: string): void { - const db = getDatabase(); - db.query("DELETE FROM api_keys WHERE key_id = $keyId").run({ $keyId: keyId }); -} - -/** - * List all stored API keys with metadata (key value excluded for security). - */ -export function listApiKeys(): Array<{ - keyId: string; - provider: string; - importedAt: number; - updatedAt: number; -}> { - const db = getDatabase(); - const rows = db - .query("SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id") - .all() as Array<Record<string, unknown>>; - return rows.map((row) => ({ - keyId: row.key_id as string, - provider: row.provider as string, - importedAt: row.imported_at as number, - updatedAt: row.updated_at as number, - })); -} diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts deleted file mode 100644 index 050a0fc..0000000 --- a/packages/core/src/credentials/claude.ts +++ /dev/null @@ -1,694 +0,0 @@ -import { createHash } from "node:crypto"; -import { - chmodSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - writeFileSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { basename, dirname, join } from "node:path"; -import { getDatabase } from "../db/index.js"; -import { getAnthropicBetas } from "./anthropic-betas.js"; -import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js"; - -// Re-exported for backward compatibility — `getAnthropicBetas` historically -// lived here and is surfaced through `credentials/index.ts`. The definition -// now lives in the dependency-free `anthropic-betas.ts` module. -export { getAnthropicBetas }; - -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 { - for (const acct of accounts) { - acct.label = acct.credentials.subscriptionType - ? `Claude ${acct.credentials.subscriptionType.charAt(0).toUpperCase() + acct.credentials.subscriptionType.slice(1)}` - : "Claude"; - } -} - -/** - * Load Claude accounts from the SQLite database. - * Returns accounts for all stored anthropic credentials. - * This is the preferred path — file-based discovery is the fallback. - */ -export function getClaudeAccountsFromDB(): ClaudeAccount[] { - const stored = listStoredCredentials(); - const accounts: ClaudeAccount[] = []; - - for (const cred of stored) { - if (cred.provider !== "anthropic") continue; - accounts.push({ - id: cred.keyId, - label: "", - source: `db:${cred.keyId}`, - credentials: { - accessToken: cred.accessToken, - refreshToken: cred.refreshToken, - expiresAt: cred.expiresAt, - subscriptionType: cred.subscriptionType ?? undefined, - }, - }); - } - - buildAccountLabels(accounts); - return accounts; -} - -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 credentials: from DB for DB-backed accounts, from file otherwise - if (account.source.startsWith("db:")) { - const stored = getStoredCredentials(account.id); - if (stored) { - account.credentials = { - accessToken: stored.accessToken, - refreshToken: stored.refreshToken, - expiresAt: stored.expiresAt, - subscriptionType: stored.subscriptionType ?? undefined, - }; - } - } else { - 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 credentials: from DB for DB-backed accounts, from file otherwise - if (account.source.startsWith("db:")) { - const stored = getStoredCredentials(account.id); - if (stored) { - account.credentials = { - accessToken: stored.accessToken, - refreshToken: stored.refreshToken, - expiresAt: stored.expiresAt, - subscriptionType: stored.subscriptionType ?? undefined, - }; - } - } else { - 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; - // Update DB if this is a DB-backed account, otherwise write to file - if (account.source.startsWith("db:")) { - updateStoredTokens( - account.id, - refreshed.accessToken, - refreshed.refreshToken, - refreshed.expiresAt, - ); - } else { - 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."; - -/** - * Build the request body for a Claude "wake" probe — a tiny, cheap message - * whose only purpose is to keep the subscription's rate-limit window warm. - * - * This MUST mirror the shape of a genuine Claude Code CLI request, because - * Anthropic validates the `system[]` array on OAuth (Pro/Max) -authenticated, - * Claude-Code-billed requests. A bare `{ model, messages }` body (no system - * identity) is rejected (401/403) — which is exactly how the old probe silently - * failed. The valid shape is: - * - * system: [ - * { type: "text", text: "x-anthropic-billing-header: ..." }, // billing, no cache_control - * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, - * ] - * messages: [ { role: "user", content: "hi" } ] - * - * Mirrors the runtime `transformClaudeOAuthBody` output for a single short user - * turn. Pure: deterministic given its inputs (the billing header samples only - * the user text), so it can be unit-tested without touching the network. - */ -export function buildWakeProbeBody(model: string): { - model: string; - max_tokens: number; - system: Array<{ type: "text"; text: string }>; - messages: Array<{ role: "user"; content: string }>; -} { - const messages = [{ role: "user" as const, content: "hi" }]; - return { - model, - max_tokens: 16, - system: [ - { type: "text", text: buildBillingHeaderValue(messages) }, - { type: "text", text: SYSTEM_IDENTITY }, - ], - messages, - }; -} - -// ─── 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; -} - -/** - * A usage report paired with provenance: whether it came back from a fresh - * live fetch against Anthropic's `/api/oauth/usage` endpoint or was served - * from the local `usage_cache` table after a failed/skipped live fetch. - * - * `source: "cache"` carries `cachedAt` — the epoch-ms timestamp recording when - * that cached payload was last fetched FROM the source (the `usage_cache.cached_at` - * column). `source: "live"` omits `cachedAt` (the data is current as of now). - */ -export interface ClaudeUsageResult { - report: ClaudeUsageReport; - source: "live" | "cache"; - /** Epoch-ms the cached report was last fetched from source. Only on `source: "cache"`. */ - cachedAt?: number; -} - -// ─── 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", -]; - -/** - * Pick the model to use for a Claude "wake" probe from a list of model ids. - * - * The probe only needs a small/cheap model to register activity against the - * subscription, so we target Haiku. Model ids change over time (the old - * hardcoded `claude-3-5-haiku-20241022` started returning HTTP 404), so the - * caller fetches the live list from `/v1/models` and we resolve by substring. - * - * Selection: the FIRST id whose name contains "haiku" (case-insensitive). - * Anthropic's `/v1/models` returns models newest-first, so first-match - * naturally prefers the newest Haiku. Returns `null` when nothing matches so - * the caller can surface a clear error instead of probing an invalid model. - */ -export function selectHaikuModel(models: string[]): string | null { - return models.find((id) => id.toLowerCase().includes("haiku")) ?? null; -} - -// ─── 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>; - // API returns utilization as 0-100 percentage; normalize to 0-1 fraction - const rawUtil = typeof b.utilization === "number" ? b.utilization : undefined; - const utilization = rawUtil !== undefined ? rawUtil / 100 : 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; - } -} - -/** - * Read a cached usage report plus the epoch-ms it was last fetched from source. - * Returns `null` when there is no cached row (or on any DB/parse error). - */ -function getCachedUsageWithMeta( - keyId: string, -): { report: ClaudeUsageReport; cachedAt: number } | null { - try { - const db = getDatabase(); - const row = db - .query("SELECT report_json, cached_at FROM usage_cache WHERE key_id = $keyId") - .get({ $keyId: keyId }) as { report_json: string; cached_at: number } | null; - if (!row) return null; - return { - report: JSON.parse(row.report_json) as ClaudeUsageReport, - cachedAt: row.cached_at, - }; - } catch { - return null; - } -} - -function setCachedUsage(keyId: string, provider: string, report: ClaudeUsageReport): void { - try { - const db = getDatabase(); - db.query( - `INSERT INTO usage_cache (key_id, provider, cached_at, report_json) - VALUES ($keyId, $provider, $cachedAt, $reportJson) - ON CONFLICT(key_id) DO UPDATE SET - cached_at = $cachedAt, - report_json = $reportJson`, - ).run({ - $keyId: keyId, - $provider: provider, - $cachedAt: Date.now(), - $reportJson: JSON.stringify(report), - }); - } catch { - // Ignore DB errors - } -} - -/** - * Fetch an account's usage report along with its provenance (live vs cache). - * - * Resolution: refresh credentials and hit the live `/api/oauth/usage` endpoint; - * on success the fresh report is cached and returned as `source: "live"`. If - * credentials cannot be refreshed OR the live fetch returns nothing, fall back - * to the local `usage_cache` row and return it as `source: "cache"` with the - * `cachedAt` timestamp recording when that payload was last fetched from source. - * Returns `null` only when neither a live report nor a cached row is available. - */ -export async function getAccountUsageWithSource( - account: ClaudeAccount, -): Promise<ClaudeUsageResult | null> { - const creds = await refreshAccountCredentialsAsync(account); - if (creds) { - const report = await fetchClaudeUsage(creds.accessToken); - if (report) { - setCachedUsage(account.id, "anthropic", report); - return { report, source: "live" }; - } - } - const cached = getCachedUsageWithMeta(account.id); - if (cached) { - return { report: cached.report, source: "cache", cachedAt: cached.cachedAt }; - } - return null; -} - -export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> { - const result = await getAccountUsageWithSource(account); - return result?.report ?? null; -} diff --git a/packages/core/src/credentials/copilot.ts b/packages/core/src/credentials/copilot.ts deleted file mode 100644 index 8baf6f4..0000000 --- a/packages/core/src/credentials/copilot.ts +++ /dev/null @@ -1,64 +0,0 @@ -// ─── GitHub Copilot Usage Tracking ─────────────────────────── -// Uses the internal GitHub Copilot user endpoint (same one the -// official VS Code Copilot extension calls). - -export interface CopilotUsageReport { - tokensConsumed?: number; - tokensRemaining?: number; - percentUsed?: number; // 0-100 - resetAt?: number; // Unix timestamp ms - plan?: string; -} - -export async function fetchCopilotUsage( - token: string, - _baseUrl: string, -): Promise<CopilotUsageReport | null> { - const url = "https://api.github.com/copilot_internal/user"; - const headers: Record<string, string> = { - authorization: `Bearer ${token}`, - accept: "application/json", - }; - - try { - const response = await fetch(url, { headers }); - if (!response.ok) return null; - - const data = (await response.json()) as Record<string, unknown>; - const plan = typeof data.copilot_plan === "string" ? data.copilot_plan : undefined; - - const resetDate = typeof data.quota_reset_date === "string" ? data.quota_reset_date : undefined; - const resetAt = resetDate ? Date.parse(resetDate) : undefined; - - const qs = data.quota_snapshots as Record<string, unknown> | undefined; - const pi = qs?.premium_interactions as Record<string, unknown> | undefined; - - const entitlement = typeof pi?.entitlement === "number" ? pi.entitlement : undefined; - const remaining = typeof pi?.remaining === "number" ? pi.remaining : undefined; - const percentRemaining = - typeof pi?.percent_remaining === "number" ? pi.percent_remaining : undefined; - - if (entitlement === undefined && remaining === undefined) { - return null; - } - - const tokensConsumed = - entitlement !== undefined && remaining !== undefined ? entitlement - remaining : undefined; - const percentUsed = - percentRemaining !== undefined - ? Math.round((100 - percentRemaining) * 100) / 100 - : tokensConsumed !== undefined && entitlement !== undefined && entitlement > 0 - ? Math.round((tokensConsumed / entitlement) * 10000) / 100 - : undefined; - - return { - tokensConsumed, - tokensRemaining: remaining, - percentUsed, - resetAt: resetAt && !Number.isNaN(resetAt) ? resetAt : undefined, - plan, - }; - } catch { - return null; - } -} diff --git a/packages/core/src/credentials/google.ts b/packages/core/src/credentials/google.ts deleted file mode 100644 index 17bc930..0000000 --- a/packages/core/src/credentials/google.ts +++ /dev/null @@ -1,178 +0,0 @@ -// ─── Google Gemini Usage Tracking ─────────────────────────── -// Two modes: -// 1. API key → queries native Gemini models endpoint for rate limits -// 2. Cookie → scrapes gemini.google.com for current usage % (like OpenCode) -// -// Set GEMINI_COOKIE env var with the value of your __Secure-1PSID cookie -// from gemini.google.com to see current usage percentages. - -export interface GoogleUsageBucket { - percentUsed: number; // 0-100 - resetsAt?: string; // e.g. "22:40" or "30 May at 17:40" -} - -export interface GoogleUsageReport { - // Mode 1: API key rate limits - models?: Array<{ - name: string; - inputTokenLimit: number; - outputTokenLimit: number; - rpm: number; - requestsPerDay: number; - }>; - // Mode 2: Cookie-scraped usage from gemini.google.com - currentUsage?: GoogleUsageBucket; - weeklyUsage?: GoogleUsageBucket; -} - -// Helpers to extract HTML text between markers -function extractBetween(html: string, after: string, before: string): string | null { - const start = html.indexOf(after); - if (start === -1) return null; - const s = start + after.length; - const end = html.indexOf(before, s); - if (end === -1) return null; - return html.slice(s, end).trim(); -} - -function extractPercent(html: string, afterMarker: string): number | null { - const chunk = extractBetween(html, afterMarker, "%"); - if (!chunk) return null; - const digits = chunk.replace(/\D/g, ""); - const n = parseInt(digits, 10); - return Number.isNaN(n) ? null : n; -} - -function extractResetTime(html: string, afterMarker: string): string | null { - // Look for "Resets at HH:MM" or "Resets on DD Mon at HH:MM" - const start = html.indexOf(afterMarker); - if (start === -1) return null; - const chunk = html.slice(start + afterMarker.length); - // Match time patterns - const m = chunk.match(/Resets?\s+(at|on)\s+([^<]+)/i); - if (!m?.[1] || !m[2]) return null; - return `${m[1]} ${m[2].trim()}`; -} - -async function scrapeGeminiWeb(cookie: string): Promise<{ - currentUsage?: GoogleUsageBucket; - weeklyUsage?: GoogleUsageBucket; -} | null> { - try { - const response = await fetch("https://gemini.google.com/app", { - headers: { - cookie: `__Secure-1PSID=${cookie}`, - "accept-language": "en-US,en;q=0.9", - }, - redirect: "follow", - }); - - if (!response.ok) return null; - - const html = await response.text(); - - // Detect auth redirect - if (html.includes("ServiceLogin") || html.includes("sign in")) { - return null; - } - - // Look for usage data in the page - // Pattern: "Current usage" followed by a percentage and reset time - const currentPct = extractPercent(html, "Current usage"); - const currentReset = extractResetTime(html, "Current usage"); - - // Weekly limit section - const weeklyPct = extractPercent(html, "Weekly limit"); - const weeklyReset = extractResetTime(html, "Weekly limit"); - - const result: { - currentUsage?: GoogleUsageBucket; - weeklyUsage?: GoogleUsageBucket; - } = {}; - - if (currentPct !== null) { - result.currentUsage = { - percentUsed: currentPct, - resetsAt: currentReset ?? undefined, - }; - } - - if (weeklyPct !== null) { - result.weeklyUsage = { - percentUsed: weeklyPct, - resetsAt: weeklyReset ?? undefined, - }; - } - - if (result.currentUsage || result.weeklyUsage) return result; - return null; - } catch { - return null; - } -} - -async function fetchModelsViaApiKey(apiKey: string): Promise<Array<{ - name: string; - inputTokenLimit: number; - outputTokenLimit: number; - rpm: number; - requestsPerDay: number; -}> | null> { - const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`; - - try { - const response = await fetch(url); - if (!response.ok) return null; - - const data = (await response.json()) as { - models?: Array<{ - name: string; - inputTokenLimit?: number; - outputTokenLimit?: number; - rateLimit?: { requestsPerMinute?: number }; - limits?: { requestsPerDay?: number }; - }>; - }; - - return (data.models ?? []) - .filter((m) => { - const name = m.name?.replace(/^models\//, "") ?? ""; - return name.startsWith("gemini-"); - }) - .map((m) => ({ - name: m.name?.replace(/^models\//, "") ?? "", - inputTokenLimit: m.inputTokenLimit ?? 0, - outputTokenLimit: m.outputTokenLimit ?? 0, - rpm: m.rateLimit?.requestsPerMinute ?? 0, - requestsPerDay: m.limits?.requestsPerDay ?? 0, - })); - } catch { - return null; - } -} - -export async function fetchGoogleUsage( - apiKey: string, - _baseUrl: string, -): Promise<GoogleUsageReport | null> { - const results: GoogleUsageReport = {}; - - // Try API key mode: get model rate limits - const models = await fetchModelsViaApiKey(apiKey); - if (models) { - results.models = models; - } - - // Try cookie mode: scrape gemini.google.com usage - const cookie = process.env.GEMINI_COOKIE; - if (cookie) { - const scraped = await scrapeGeminiWeb(cookie); - if (scraped) { - if (scraped.currentUsage) results.currentUsage = scraped.currentUsage; - if (scraped.weeklyUsage) results.weeklyUsage = scraped.weeklyUsage; - } - } - - if (results.models || results.currentUsage || results.weeklyUsage) return results; - return null; -} diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts deleted file mode 100644 index 131f035..0000000 --- a/packages/core/src/credentials/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -export { - deleteApiKey, - getApiKey, - listApiKeys, - resolveApiKey, - type StoredApiKey, - setApiKey, -} from "./api-keys.js"; -export { - ANTHROPIC_MODELS_FALLBACK, - buildBillingHeaderValue, - buildWakeProbeBody, - type ClaudeAccount, - type ClaudeCredentials, - type ClaudeProfile, - type ClaudeUsageBucket, - type ClaudeUsageReport, - type ClaudeUsageResult, - discoverClaudeAccounts, - fetchAnthropicModels, - getAccountUsage, - getAccountUsageWithSource, - getAnthropicBetas, - getAnthropicHeaders, - getClaudeAccountsFromDB, - refreshAccountCredentials, - refreshAccountCredentialsAsync, - SYSTEM_IDENTITY, - selectHaikuModel, - validateAccountCredentials, -} from "./claude.js"; -export { - type CopilotUsageReport, - fetchCopilotUsage, -} from "./copilot.js"; -export { - fetchGoogleUsage, - type GoogleUsageReport, -} from "./google.js"; -export { - fetchOpencodeUsage, - type OpencodeUsageBucket, - type OpencodeUsageReport, -} from "./opencode.js"; -export { - deleteStoredCredentials, - getStoredCredentials, - importCredentialsFromFile, - listStoredCredentials, - type StoredCredential, - updateStoredTokens, -} from "./store.js"; diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts deleted file mode 100644 index d4d4851..0000000 --- a/packages/core/src/credentials/opencode.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { resolveApiKey } from "./api-keys.js"; - -// ─── OpenCode Usage Tracking ────────────────────────────────── -// OpenCode has no public usage API. We scrape usage from the -// SolidStart SSR-rendered workspace page using a session cookie. -// Requires OPENCODE_COOKIE env var. -// Workspace IDs: OPENCODE_WS1_ID for opencode-1, OPENCODE_WS2_ID for opencode-2. - -export interface OpencodeUsageBucket { - utilization?: number; // 0-1 fraction - resetsAt?: number; // Unix timestamp ms -} - -export interface OpencodeUsageReport { - fiveHour?: OpencodeUsageBucket; - weekly?: OpencodeUsageBucket; - monthly?: OpencodeUsageBucket; -} - -function getWorkspaceId(keyId: string): string | undefined { - // Check DB for workspace ID: stored as "opencode-ws1", "opencode-ws2", or "opencode-ws" - const match = keyId.match(/opencode-(\d+)$/i); - if (match) { - const num = match[1]; - const specific = resolveApiKey(`opencode-ws${num}`); - if (specific) return specific; - } - return resolveApiKey("opencode-ws") ?? undefined; -} - -function parseOcDouble(html: string, key: string): number { - const idx = html.indexOf(`${key}:`); - if (idx === -1) return 0; - let start = idx + key.length + 1; - while (start < html.length && html[start] === " ") start++; - let end = start; - while (end < html.length && html[end] !== "," && html[end] !== "}") { - end++; - } - const val = parseFloat(html.slice(start, end)); - return Number.isNaN(val) ? 0 : val; -} - -function parseOcInt(html: string, key: string): number { - const idx = html.indexOf(`${key}:`); - if (idx === -1) return 0; - let i = idx + key.length + 1; - while (i < html.length && html[i] === " ") i++; - return parseInt(html.slice(i), 10) || 0; -} - -function parseOcBucket( - html: string, - bucketName: string, -): { utilization: number; resetsAt: number } | null { - const search = `${bucketName}:`; - const pos = html.indexOf(search); - if (pos === -1) return null; - - // Find the opening brace after the bucket name - const brace = html.indexOf("{", pos); - if (brace === -1) return null; - - const resetSecs = parseOcInt(html.slice(brace), "resetInSec"); - const usagePct = parseOcDouble(html.slice(brace), "usagePercent"); - - const utilization = usagePct / 100; // convert 0-100% to 0-1 fraction - const resetsAt = Date.now() + resetSecs * 1000; - - return { utilization, resetsAt }; -} - -export async function fetchOpencodeUsage(keyId: string): Promise<OpencodeUsageReport | null> { - const cookie = resolveApiKey("opencode-cookie"); - const wsId = getWorkspaceId(keyId); - - if (!cookie || !wsId) { - return null; - } - - const url = `https://opencode.ai/workspace/${encodeURIComponent(wsId)}/go`; - - try { - const response = await fetch(url, { - headers: { - accept: "text/html", - cookie: `auth=${cookie}`, - }, - redirect: "follow", - }); - - if (!response.ok) return null; - - const html = await response.text(); - - // Auth redirect check - if (html.includes("/auth/authorize") || html.includes('window.location="/auth/authorize"')) { - return null; - } - - // Find the lite.subscription data block. - // HTML contains: lite.subscription.get[\"<wsId>\"] - // We need literal backslashes; use \x5c (hex for backslash). - const wsKey = `lite.subscription.get[\x5c"${wsId}\x5c"]`; - const wsPos = html.indexOf(wsKey); - if (wsPos === -1) return null; - - // Search for the resolved data starting from the ws key position - const minePos = html.indexOf("mine:", wsPos); - const slice = minePos !== -1 ? html.slice(minePos) : ""; - - const fiveHour = parseOcBucket(slice, "rollingUsage"); - const weekly = parseOcBucket(slice, "weeklyUsage"); - const monthly = parseOcBucket(slice, "monthlyUsage"); - - if (!fiveHour && !weekly && !monthly) return null; - - return { - fiveHour: fiveHour ?? undefined, - weekly: weekly ?? undefined, - monthly: monthly ?? undefined, - }; - } catch { - return null; - } -} diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts deleted file mode 100644 index 662b322..0000000 --- a/packages/core/src/credentials/store.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { getDatabase } from "../db/index.js"; -import type { ClaudeCredentials } from "./claude.js"; - -export interface StoredCredential { - keyId: string; - provider: string; - accessToken: string; - refreshToken: string; - expiresAt: number; - subscriptionType: string | null; - sourceFile: string | null; - importedAt: number; - updatedAt: 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.mcpOAuth && !creds.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, - }; -} - -/** - * Import credentials from a file into the database for a specific key. - * Reads the credential file, parses it, and upserts into the credentials table. - */ -export function importCredentialsFromFile( - keyId: string, - provider: string, - filePath: string, -): { success: boolean; error?: string } { - if (!existsSync(filePath)) { - return { success: false, error: `File not found: ${filePath}` }; - } - - let raw: string; - try { - raw = readFileSync(filePath, "utf-8").trim(); - } catch (e) { - return { - success: false, - error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`, - }; - } - - if (!raw) { - return { success: false, error: "File is empty" }; - } - - const creds = parseCredentialsFile(raw); - if (!creds) { - return { success: false, error: "Invalid credentials format" }; - } - - const db = getDatabase(); - const now = Date.now(); - - db.query( - `INSERT INTO credentials (key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at) - VALUES ($keyId, $provider, $accessToken, $refreshToken, $expiresAt, $subscriptionType, $sourceFile, $now, $now) - ON CONFLICT(key_id) DO UPDATE SET - access_token = $accessToken, - refresh_token = $refreshToken, - expires_at = $expiresAt, - subscription_type = $subscriptionType, - source_file = $sourceFile, - updated_at = $now`, - ).run({ - $keyId: keyId, - $provider: provider, - $accessToken: creds.accessToken, - $refreshToken: creds.refreshToken, - $expiresAt: creds.expiresAt, - $subscriptionType: creds.subscriptionType ?? null, - $sourceFile: filePath, - $now: now, - }); - - return { success: true }; -} - -/** - * Get stored credentials for a specific key from the database. - */ -export function getStoredCredentials(keyId: string): StoredCredential | null { - const db = getDatabase(); - const row = db - .query( - "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId", - ) - .get({ $keyId: keyId }) as Record<string, unknown> | null; - - if (!row) return null; - - return { - keyId: row.key_id as string, - provider: row.provider as string, - accessToken: row.access_token as string, - refreshToken: row.refresh_token as string, - expiresAt: row.expires_at as number, - subscriptionType: row.subscription_type as string | null, - sourceFile: row.source_file as string | null, - importedAt: row.imported_at as number, - updatedAt: row.updated_at as number, - }; -} - -/** - * Update tokens in the database after a refresh. - */ -export function updateStoredTokens( - keyId: string, - accessToken: string, - refreshToken: string, - expiresAt: number, -): void { - const db = getDatabase(); - db.query( - `UPDATE credentials SET access_token = $accessToken, refresh_token = $refreshToken, expires_at = $expiresAt, updated_at = $now WHERE key_id = $keyId`, - ).run({ - $keyId: keyId, - $accessToken: accessToken, - $refreshToken: refreshToken, - $expiresAt: expiresAt, - $now: Date.now(), - }); -} - -/** - * Delete stored credentials for a key. - */ -export function deleteStoredCredentials(keyId: string): void { - const db = getDatabase(); - db.query("DELETE FROM credentials WHERE key_id = $keyId").run({ $keyId: keyId }); -} - -/** - * List all keys that have imported credentials, with their status. - */ -export function listStoredCredentials(): StoredCredential[] { - const db = getDatabase(); - const rows = db - .query( - "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id", - ) - .all() as Array<Record<string, unknown>>; - - return rows.map((row) => ({ - keyId: row.key_id as string, - provider: row.provider as string, - accessToken: row.access_token as string, - refreshToken: row.refresh_token as string, - expiresAt: row.expires_at as number, - subscriptionType: row.subscription_type as string | null, - sourceFile: row.source_file as string | null, - importedAt: row.imported_at as number, - updatedAt: row.updated_at as number, - })); -} |
