summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/credentials
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/credentials
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-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.ts24
-rw-r--r--packages/core/src/credentials/api-keys.ts80
-rw-r--r--packages/core/src/credentials/claude.ts694
-rw-r--r--packages/core/src/credentials/copilot.ts64
-rw-r--r--packages/core/src/credentials/google.ts178
-rw-r--r--packages/core/src/credentials/index.ts52
-rw-r--r--packages/core/src/credentials/opencode.ts126
-rw-r--r--packages/core/src/credentials/store.ts185
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,
- }));
-}