summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 17:30:08 +0900
committerAdam Malczewski <[email protected]>2026-05-21 17:30:08 +0900
commitd6b208342edf97bafa5b1dcc986b782f9879d141 (patch)
treec6d8f9bffa86f78e3b1369b811bef9642df788d0 /packages/api/src
parent1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 (diff)
downloaddispatch-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/src')
-rw-r--r--packages/api/src/agent-manager.ts29
-rw-r--r--packages/api/src/routes/models.ts202
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