diff options
| author | Adam Malczewski <[email protected]> | 2026-05-21 17:30:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-21 17:30:08 +0900 |
| commit | d6b208342edf97bafa5b1dcc986b782f9879d141 (patch) | |
| tree | c6d8f9bffa86f78e3b1369b811bef9642df788d0 /packages/api | |
| parent | 1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 (diff) | |
| download | dispatch-d6b208342edf97bafa5b1dcc986b782f9879d141.tar.gz dispatch-d6b208342edf97bafa5b1dcc986b782f9879d141.zip | |
feat: SQLite database for all credentials, keys, wake schedule, and usage cache
- Add SQLite database at ~/.local/share/dispatch/dispatch.db with tables: credentials, api_keys, wake_schedule, usage_cache
- Store Claude OAuth credentials in DB with import button in Model Status UI
- Store OpenCode/Copilot API keys in DB with paste-to-import modal
- Store OpenCode cookie and workspace IDs in DB
- Migrate wake schedule from .wake-schedule.json to DB
- Migrate usage cache from in-memory Map + localStorage to DB
- Remove all env var and file fallbacks — DB is the single source of truth
- Add seed scripts: bin/import-credentials.ts, bin/seed-opencode-keys.ts
- Docker: container runs as host UID/GID with matching home directory
- Clean up dispatch.toml: remove env fields, update comments
- Progress bar time markers for usage cycle tracking
Diffstat (limited to 'packages/api')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 29 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 202 |
2 files changed, 156 insertions, 75 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 4f45781..92396b6 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -20,9 +20,10 @@ import { TaskList, createTaskListTool, type ClaudeAccount, - discoverClaudeAccounts, + getClaudeAccountsFromDB, refreshAccountCredentials, refreshAccountCredentialsAsync, + resolveApiKey, } from "@dispatch/core"; import type { PermissionManager } from "./permission-manager.js"; import { setConfigGetter } from "./routes/config.js"; @@ -124,7 +125,7 @@ export class AgentManager { private _refreshClaudeAccounts(): void { try { - this.claudeAccounts = discoverClaudeAccounts(); + this.claudeAccounts = getClaudeAccountsFromDB(); if (this.claudeAccounts.length > 0) { console.log(`dispatch: discovered ${this.claudeAccounts.length} Claude account(s)`); } @@ -187,8 +188,8 @@ export class AgentManager { const ruleset = configToRuleset(this.config); // Try to resolve model from registry, fall back to env vars - let apiKey = process.env.OPENCODE_API_KEY ?? ""; - let model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash"; + let apiKey = ""; + let model = "deepseek-v4-flash"; let baseURL = "https://opencode.ai/zen/go/v1"; let provider: string | undefined; let claudeCredentials: { accessToken: string } | undefined; @@ -203,9 +204,8 @@ export class AgentManager { if (key.provider === "anthropic") { // Anthropic provider: resolve credentials from Claude accounts const credFile = key.credentials_file; - const account = credFile - ? this.claudeAccounts.find((a) => a.source === credFile) - : this.claudeAccounts[0]; + const account = this.claudeAccounts.find((a) => a.id === effectiveKeyId) + ?? (credFile ? this.claudeAccounts.find((a) => a.source === credFile) : this.claudeAccounts[0]); if (account) { const creds = refreshAccountCredentials(account); if (creds && creds.expiresAt > Date.now() + 60_000) { @@ -247,7 +247,7 @@ export class AgentManager { } } else { // Standard key: resolve from env var - const envKey = key.env ? process.env[key.env] : undefined; + const envKey = resolveApiKey(key.id); if (envKey) { apiKey = envKey; baseURL = key.base_url; @@ -284,9 +284,8 @@ export class AgentManager { // Check if resolved key is anthropic if (resolved.key.provider === "anthropic") { const credFile = resolved.key.credentials_file; - const account = credFile - ? this.claudeAccounts.find((a) => a.source === credFile) - : this.claudeAccounts[0]; + const account = this.claudeAccounts.find((a) => a.id === resolved.key.id) + ?? (credFile ? this.claudeAccounts.find((a) => a.source === credFile) : this.claudeAccounts[0]); if (account) { let creds = refreshAccountCredentials(account); if (!creds || creds.expiresAt <= Date.now() + 60_000) { @@ -304,14 +303,14 @@ export class AgentManager { console.warn(`dispatch: no Claude credentials found for key "${resolved.key.id}"`); } } else { - const envKey = process.env[resolved.key.env!]; + const envKey = resolveApiKey(resolved.key.id); if (envKey) { apiKey = envKey; } else { - console.warn(`dispatch: env var "${resolved.key.env}" not set for key "${resolved.key.id}", falling back to env vars`); - model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash"; + console.warn(`dispatch: env var not set for key "${resolved.key.id}", falling back to defaults`); + model = "deepseek-v4-flash"; baseURL = "https://opencode.ai/zen/go/v1"; - apiKey = process.env.OPENCODE_API_KEY ?? ""; + apiKey = ""; } } } else { diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 2f11268..ca58e38 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -1,16 +1,20 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import type { ModelRegistry, ModelResolver } from "@dispatch/core"; import { ANTHROPIC_MODELS_FALLBACK, type ClaudeAccount, - discoverClaudeAccounts, fetchAnthropicModels, + getClaudeAccountsFromDB, fetchCopilotUsage, fetchOpencodeUsage, getAccountUsage, getAnthropicHeaders, + getDatabase, + importCredentialsFromFile, + listApiKeys, + listStoredCredentials, refreshAccountCredentialsAsync, + resolveApiKey, + setApiKey, validateAccountCredentials, } from "@dispatch/core"; import { Hono } from "hono"; @@ -31,6 +35,11 @@ export function setAccountsGetter(getter: () => ClaudeAccount[]): void { getAccounts = getter; } +/** Load Claude accounts from the database. */ +function resolveClaudeAccounts(): ClaudeAccount[] { + return getClaudeAccountsFromDB(); +} + export const modelsRoutes = new Hono(); modelsRoutes.get("/", (c) => { @@ -108,8 +117,9 @@ modelsRoutes.get("/available", async (c) => { // Anthropic provider: validate credentials and fetch models dynamically if (key.definition.provider === "anthropic") { const credFile = key.definition.credentials_file; - const accounts = discoverClaudeAccounts(); - const account = credFile ? accounts.find((a) => a.source === credFile) : accounts[0]; + const accounts = resolveClaudeAccounts(); + const account = accounts.find((a) => a.id === keyId) + ?? (credFile ? accounts.find((a) => a.source === credFile) : accounts[0]); if (!account) { return c.json({ error: "no Claude credentials found" }, 500); @@ -139,9 +149,9 @@ modelsRoutes.get("/available", async (c) => { }); } - const apiKeyValue = key.definition.env ? process.env[key.definition.env] : undefined; + const apiKeyValue = resolveApiKey(keyId!); if (!apiKeyValue) { - return c.json({ error: `env var not set: ${key.definition.env}` }, 500); + return c.json({ error: `no API key found for ${keyId}` }, 500); } const baseUrl = key.definition.base_url.replace(/\/+$/, ""); @@ -181,7 +191,7 @@ modelsRoutes.get("/available", async (c) => { // List available Claude accounts with validated credentials modelsRoutes.get("/claude-accounts", async (c) => { - const candidates = discoverClaudeAccounts(); + const candidates = resolveClaudeAccounts(); // Validate each account's credentials; only include ones with a working token const validated: Array<{ @@ -214,7 +224,7 @@ modelsRoutes.get("/claude-accounts", async (c) => { modelsRoutes.get("/claude-usage", async (c) => { const accountId = c.req.query("accountId"); const accounts = getAccounts(); - const accountAccounts = discoverClaudeAccounts(); + const accountAccounts = resolveClaudeAccounts(); const allAccounts = accounts.length > 0 ? accounts : accountAccounts; let account: ClaudeAccount | undefined; @@ -261,12 +271,15 @@ modelsRoutes.get("/key-usage", async (c) => { try { if (provider === "anthropic") { - const allAccounts = discoverClaudeAccounts(); + const allAccounts = resolveClaudeAccounts(); const credFile = key.definition.credentials_file; - // Only show the account matching this key's credentials_file - const accounts = credFile - ? allAccounts.filter((a) => a.source === credFile) - : allAccounts.slice(0, 1); // no credentials_file → default account only + // Match by key ID (DB accounts) or source file (file accounts) + const accounts = allAccounts.filter( + (a) => a.id === keyId || (credFile && a.source === credFile), + ); + if (accounts.length === 0 && allAccounts[0]) { + accounts.push(allAccounts[0]); + } if (accounts.length === 0) { return c.json({ error: "no Claude accounts available" }, 502); } @@ -315,12 +328,9 @@ modelsRoutes.get("/key-usage", async (c) => { }, }); } else if (provider === "github-copilot") { - if (!key.definition.env) { - return c.json({ error: "no env var configured for this key" }, 502); - } - const token = process.env[key.definition.env]; + const token = resolveApiKey(keyId); if (!token) { - return c.json({ error: `env var ${key.definition.env} not set` }, 502); + return c.json({ error: `no API key found for ${keyId}` }, 502); } const report = await fetchCopilotUsage(token, key.definition.base_url); if (!report) { @@ -343,29 +353,106 @@ modelsRoutes.get("/key-usage", async (c) => { } }); +// ─── API key management ─────────────────────────────────────── + +modelsRoutes.post("/set-api-key", async (c) => { + const body = await c.req.json<{ keyId?: string; apiKey?: string }>(); + if (typeof body.keyId !== "string" || !body.keyId) { + return c.json({ error: "keyId is required" }, 400); + } + if (typeof body.apiKey !== "string" || !body.apiKey) { + return c.json({ error: "apiKey is required" }, 400); + } + + const registry = getRegistry(); + if (!registry) { + return c.json({ error: "registry not available" }, 502); + } + + const keys = registry.getKeys(); + const key = keys.find((k) => k.definition.id === body.keyId); + if (!key) { + return c.json({ error: `key not found: ${body.keyId}` }, 404); + } + + setApiKey(body.keyId, key.definition.provider, body.apiKey); + return c.json({ success: true, keyId: body.keyId }); +}); + +modelsRoutes.get("/api-keys-status", (c) => { + const stored = listApiKeys(); + return c.json({ keys: stored }); +}); + +// ─── Credential import ──────────────────────────────────────── + +modelsRoutes.post("/import-credentials", async (c) => { + const body = await c.req.json<{ keyId?: string }>(); + const keyId = body.keyId; + if (typeof keyId !== "string" || !keyId) { + return c.json({ error: "keyId is required" }, 400); + } + + const registry = getRegistry(); + if (!registry) { + return c.json({ error: "registry not available" }, 502); + } + + const keys = registry.getKeys(); + const key = keys.find((k) => k.definition.id === keyId); + if (!key) { + return c.json({ error: `key not found: ${keyId}` }, 404); + } + + if (key.definition.provider !== "anthropic") { + return c.json({ error: "credential import is only supported for anthropic keys" }, 400); + } + + const credFile = key.definition.credentials_file; + if (!credFile) { + return c.json({ error: "no credentials_file configured for this key" }, 400); + } + + const result = importCredentialsFromFile(keyId, key.definition.provider, credFile); + if (!result.success) { + return c.json({ error: result.error ?? "import failed" }, 400); + } + + return c.json({ success: true, keyId }); +}); + +modelsRoutes.get("/credentials-status", (c) => { + const stored = listStoredCredentials(); + const status = stored.map((cred) => ({ + keyId: cred.keyId, + provider: cred.provider, + subscriptionType: cred.subscriptionType, + sourceFile: cred.sourceFile, + importedAt: cred.importedAt, + updatedAt: cred.updatedAt, + expired: cred.expiresAt < Date.now(), + })); + return c.json({ credentials: status }); +}); + // ─── Shared wake function ───────────────────────────────────── async function wakeAllClaudeAccounts(): Promise< Array<{ label: string; ok: boolean; error?: string }> > { // Only wake accounts referenced by configured anthropic keys - const allAccounts = discoverClaudeAccounts(); + const allAccounts = resolveClaudeAccounts(); const registry = getRegistry(); - const configuredCredFiles = new Set<string>(); + const configuredKeyIds = new Set<string>(); if (registry) { for (const ks of registry.getKeys()) { if (ks.definition.provider === "anthropic") { - if (ks.definition.credentials_file) { - configuredCredFiles.add(ks.definition.credentials_file); - } else if (allAccounts[0]) { - // Key without explicit credentials_file uses default account - configuredCredFiles.add(allAccounts[0].source); - } + configuredKeyIds.add(ks.definition.id); } } } - const accounts = configuredCredFiles.size > 0 - ? allAccounts.filter((a) => configuredCredFiles.has(a.source)) + const accounts = configuredKeyIds.size > 0 + ? allAccounts.filter((a) => configuredKeyIds.has(a.id)) : allAccounts; if (accounts.length === 0) { return [{ label: "(none)", ok: false, error: "no Claude accounts available" }]; @@ -421,8 +508,6 @@ interface PendingRetry { nextRetryAt: number; // timestamp for next retry attempt } -const SCHEDULE_FILE = join(process.cwd(), ".wake-schedule.json"); - function nextOccurrenceAt15(hour: number): number { const now = new Date(); const target = new Date(now); @@ -433,47 +518,44 @@ function nextOccurrenceAt15(hour: number): number { return target.getTime(); } -function loadScheduleFromDisk(): WakeSchedule { +function loadScheduleFromDB(): WakeSchedule { try { - if (existsSync(SCHEDULE_FILE)) { - const raw = readFileSync(SCHEDULE_FILE, "utf-8"); - const parsed = JSON.parse(raw) as Record<string, number>; - const schedule: WakeSchedule = {}; - let needsPersist = false; - for (const [key, value] of Object.entries(parsed)) { - const hour = Number(key); - if (value > Date.now()) { - schedule[hour] = value; - } else { - // Timestamp has passed — recompute for next occurrence - schedule[hour] = nextOccurrenceAt15(hour); - needsPersist = true; - } + const db = getDatabase(); + const rows = db.query("SELECT hour, next_wake_at FROM wake_schedule").all() as Array<{ hour: number; next_wake_at: number }>; + const schedule: WakeSchedule = {}; + let needsUpdate = false; + for (const row of rows) { + if (row.next_wake_at > Date.now()) { + schedule[row.hour] = row.next_wake_at; + } else { + schedule[row.hour] = nextOccurrenceAt15(row.hour); + needsUpdate = true; } - if (needsPersist) { - try { - writeFileSync(SCHEDULE_FILE, JSON.stringify(schedule), "utf-8"); - } catch { - // Ignore write errors - } - } - return schedule; } + if (needsUpdate) { + persistSchedule(schedule); + } + return schedule; } catch { - // File doesn't exist or is corrupt — start fresh + return {}; } - return {}; } -function persistSchedule(): void { +function persistSchedule(scheduleToSave?: WakeSchedule): void { try { - writeFileSync(SCHEDULE_FILE, JSON.stringify(wakeSchedule), "utf-8"); + const db = getDatabase(); + const data = scheduleToSave ?? wakeSchedule; + db.run("DELETE FROM wake_schedule"); + const insert = db.query("INSERT INTO wake_schedule (hour, next_wake_at) VALUES ($hour, $nextWakeAt)"); + for (const [hour, nextWakeAt] of Object.entries(data)) { + insert.run({ $hour: Number(hour), $nextWakeAt: nextWakeAt }); + } } catch { - // Ignore write errors + // Ignore DB errors } } -let wakeSchedule: WakeSchedule = loadScheduleFromDisk(); +let wakeSchedule: WakeSchedule = loadScheduleFromDB(); let pendingRetries: PendingRetry[] = []; // HMR-safe: clear previous tick before starting a new one |
