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 | |
| 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
| -rwxr-xr-x | bin/import-credentials.ts | 26 | ||||
| -rw-r--r-- | bin/seed-opencode-keys.ts | 28 | ||||
| -rwxr-xr-x | bin/up | 5 | ||||
| -rwxr-xr-x | bin/up-backend | 8 | ||||
| -rw-r--r-- | dispatch.toml | 14 | ||||
| -rw-r--r-- | docker-compose.yml | 10 | ||||
| -rw-r--r-- | docker/entrypoint.dev.sh | 52 | ||||
| -rw-r--r-- | packages/api/src/agent-manager.ts | 29 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 202 | ||||
| -rw-r--r-- | packages/core/src/config/schema.ts | 8 | ||||
| -rw-r--r-- | packages/core/src/credentials/api-keys.ts | 71 | ||||
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 116 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 17 | ||||
| -rw-r--r-- | packages/core/src/credentials/opencode.ts | 10 | ||||
| -rw-r--r-- | packages/core/src/credentials/store.ts | 177 | ||||
| -rw-r--r-- | packages/core/src/db/index.ts | 93 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 3 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/KeyUsage.svelte | 141 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ModelStatus.svelte | 200 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 2 |
20 files changed, 1028 insertions, 184 deletions
diff --git a/bin/import-credentials.ts b/bin/import-credentials.ts new file mode 100755 index 0000000..f99f20a --- /dev/null +++ b/bin/import-credentials.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env bun +/** + * Import Claude credentials into the Dispatch SQLite database. + * Reads from ~/.claude/.credentials-{1,2}.json and stores them + * for the configured keys (claude-pro, claude-max). + */ +import { importCredentialsFromFile, getDatabasePath } from "../packages/core/src/index.js"; + +const imports = [ + { keyId: "claude-pro", provider: "anthropic", file: `${process.env.HOME}/.claude/.credentials-1.json` }, + { keyId: "claude-max", provider: "anthropic", file: `${process.env.HOME}/.claude/.credentials-2.json` }, +]; + +console.log(`Database: ${getDatabasePath()}\n`); + +for (const { keyId, provider, file } of imports) { + process.stdout.write(`Importing ${keyId} from ${file} ... `); + const result = importCredentialsFromFile(keyId, provider, file); + if (result.success) { + console.log("OK"); + } else { + console.log(`FAILED: ${result.error}`); + } +} + +console.log("\nDone."); diff --git a/bin/seed-opencode-keys.ts b/bin/seed-opencode-keys.ts new file mode 100644 index 0000000..c461592 --- /dev/null +++ b/bin/seed-opencode-keys.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env bun +/** + * Seed API keys from environment variables into the SQLite database. + */ +import { setApiKey, getDatabasePath } from "../packages/core/src/index.js"; + +console.log(`Database: ${getDatabasePath()}\n`); + +const keys = [ + { keyId: "opencode-1", provider: "opencode-go", envVar: "OPENCODE_KEY_1" }, + { keyId: "opencode-2", provider: "opencode-go", envVar: "OPENCODE_KEY_2" }, + { keyId: "copilot", provider: "github-copilot", envVar: "COPILOT_TOKEN" }, + { keyId: "opencode-cookie", provider: "opencode-go", envVar: "OPENCODE_COOKIE" }, + { keyId: "opencode-ws1", provider: "opencode-go", envVar: "OPENCODE_WS1_ID" }, + { keyId: "opencode-ws2", provider: "opencode-go", envVar: "OPENCODE_WS2_ID" }, +]; + +for (const { keyId, provider, envVar } of keys) { + const value = process.env[envVar]; + if (value) { + setApiKey(keyId, provider, value); + console.log(`${keyId}: imported from $${envVar}`); + } else { + console.log(`${keyId}: SKIPPED ($${envVar} not set)`); + } +} + +console.log("\nDone."); @@ -10,6 +10,11 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # Load secrets from gopass OPENCODE_API_KEY="$(gopass show -o projects/ai-api/opencode_go_key)" +# Pass host user identity so the container runs as the same UID/GID +export HOST_UID="$(id -u)" +export HOST_GID="$(id -g)" +export HOST_USER="$(whoami)" + # Start all services OPENCODE_API_KEY="$OPENCODE_API_KEY" \ docker compose -f "$PROJECT_DIR/docker-compose.yml" up "$@" diff --git a/bin/up-backend b/bin/up-backend index 722e5eb..894fc01 100755 --- a/bin/up-backend +++ b/bin/up-backend @@ -10,6 +10,12 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # Load secrets from gopass OPENCODE_API_KEY="$(gopass show -o projects/ai-api/opencode_go_key)" +# Pass host user identity so the container runs as the same UID/GID +export HOST_UID="$(id -u)" +export HOST_GID="$(id -g)" +export HOST_USER="$(whoami)" + # Start API service only -sudo OPENCODE_API_KEY="$OPENCODE_API_KEY" \ +sudo -E OPENCODE_API_KEY="$OPENCODE_API_KEY" \ + HOST_UID="$HOST_UID" HOST_GID="$HOST_GID" HOST_USER="$HOST_USER" \ docker compose -f "$PROJECT_DIR/docker-compose.yml" up api "$@" diff --git a/dispatch.toml b/dispatch.toml index 7e6fa5a..6789e95 100644 --- a/dispatch.toml +++ b/dispatch.toml @@ -1,11 +1,8 @@ # Dispatch — Model & Key Configuration -# Keys reference env var names in .env.dispatch +# Credentials and API keys are stored in the SQLite database. +# Use the Model Status panel to import credentials, or run bin/import-credentials.ts. # ─── Fallback Order (highest priority first) ──────────────────── -# Exhaust claude-max first, then opencode-1, then opencode-2, then copilot. -# When all keys are exhausted the agent enters wait-for-refresh. -# Must be declared BEFORE any [[keys]] / [[models]] blocks. - fallback = ["claude-pro", "claude-max", "opencode-1", "opencode-2", "copilot"] # ─── API Keys ─────────────────────────────────────────────────── @@ -14,30 +11,27 @@ fallback = ["claude-pro", "claude-max", "opencode-1", "opencode-2", "copilot"] id = "claude-pro" provider = "anthropic" base_url = "https://api.anthropic.com/v1" -credentials_file = "/root/.claude/.credentials-1.json" +credentials_file = "/home/tradam/.claude/.credentials-1.json" [[keys]] id = "claude-max" provider = "anthropic" base_url = "https://api.anthropic.com/v1" -credentials_file = "/root/.claude/.credentials-2.json" +credentials_file = "/home/tradam/.claude/.credentials-2.json" [[keys]] id = "opencode-1" provider = "opencode-go" -env = "OPENCODE_KEY_1" base_url = "https://opencode.ai/zen/go/v1" [[keys]] id = "opencode-2" provider = "opencode-go" -env = "OPENCODE_KEY_2" base_url = "https://opencode.ai/zen/go/v1" [[keys]] id = "copilot" provider = "github-copilot" -env = "COPILOT_TOKEN" base_url = "https://api.githubcopilot.com" # ─── Models ───────────────────────────────────────────────────── diff --git a/docker-compose.yml b/docker-compose.yml index 7a67505..3a019e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,10 +8,14 @@ services: - "3000:3000" volumes: - .:/app - - ~/.claude:/root/.claude + - ${HOME}/.claude:/home/${HOST_USER:-dispatch}/.claude + - ${HOME}/.local/share/dispatch:/home/${HOST_USER:-dispatch}/.local/share/dispatch env_file: - .env.dispatch environment: + HOST_UID: ${HOST_UID:-1000} + HOST_GID: ${HOST_GID:-1000} + HOST_USER: ${HOST_USER:-dispatch} DISPATCH_WORKING_DIR: /app frontend: @@ -23,3 +27,7 @@ services: - "5173:5173" volumes: - .:/app + environment: + HOST_UID: ${HOST_UID:-1000} + HOST_GID: ${HOST_GID:-1000} + HOST_USER: ${HOST_USER:-dispatch} diff --git a/docker/entrypoint.dev.sh b/docker/entrypoint.dev.sh index b28b44f..8378585 100644 --- a/docker/entrypoint.dev.sh +++ b/docker/entrypoint.dev.sh @@ -1,11 +1,47 @@ #!/bin/bash set -euo pipefail -# Install/update dependencies. -# Source code is bind-mounted from the host; node_modules lives on the host -# filesystem via the bind mount so it persists across container restarts -# and is shared between the api and frontend services. -bun install - -# Execute the main command -exec "$@" +# ─── Match host user inside container ──────────────────────────── +# Ensures the process runs as the host UID/GID with a matching +# home directory so volume mounts and config paths are consistent. + +HOST_UID="${HOST_UID:-1000}" +HOST_GID="${HOST_GID:-1000}" +HOST_USER="${HOST_USER:-dispatch}" + +USER_HOME="/home/$HOST_USER" + +# Create group if it doesn't exist +if ! getent group "$HOST_GID" > /dev/null 2>&1; then + groupadd -g "$HOST_GID" "$HOST_USER" +fi + +# Ensure user with this UID has the correct home directory +if id -u "$HOST_UID" > /dev/null 2>&1; then + USER_NAME=$(getent passwd "$HOST_UID" | cut -d: -f1) + usermod -d "$USER_HOME" "$USER_NAME" 2>/dev/null || true +else + useradd -u "$HOST_UID" -g "$HOST_GID" -d "$USER_HOME" -m -s /bin/bash "$HOST_USER" + USER_NAME="$HOST_USER" +fi + +# Ensure home and data directories exist with correct ownership +mkdir -p "$USER_HOME" "$USER_HOME/.local/share/dispatch" +chown "$HOST_UID:$HOST_GID" "$USER_HOME" +chown -R "$HOST_UID:$HOST_GID" "$USER_HOME/.local/share/dispatch" + +# Ensure .claude is accessible +if [ -d "$USER_HOME/.claude" ]; then + chown -R "$HOST_UID:$HOST_GID" "$USER_HOME/.claude" 2>/dev/null || true +fi + +# Ensure node_modules is writable (created as root during build) +if [ -d /app/node_modules ]; then + chown -R "$HOST_UID:$HOST_GID" /app/node_modules +fi + +# Install/update dependencies as the target user +su -s /bin/bash - "$USER_NAME" -c "export HOME=$USER_HOME && cd /app && bun install" + +# Execute the main command as the target user +exec su -s /bin/bash - "$USER_NAME" -c "export HOME=$USER_HOME && cd /app && exec $*" 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 diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 57d0b7b..83e2695 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -149,16 +149,12 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi }; } - // Other providers require env - if (typeof raw["env"] !== "string") { - errors.push({ path: `${path}.env`, message: "must be a string" }); - return null; - } + // Other providers: env is optional (keys can be stored in DB) return { id: raw["id"] as string, provider: raw["provider"] as string, - env: raw["env"] as string, base_url: raw["base_url"] as string, + ...(typeof raw["env"] === "string" ? { env: raw["env"] } : {}), }; } diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts new file mode 100644 index 0000000..5f92ffa --- /dev/null +++ b/packages/core/src/credentials/api-keys.ts @@ -0,0 +1,71 @@ +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. Returns null if not found. + */ +export function resolveApiKey(keyId: string): string | null { + return getApiKey(keyId); +} + +/** + * 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 index 6639afc..1b9d148 100644 --- a/packages/core/src/credentials/claude.ts +++ b/packages/core/src/credentials/claude.ts @@ -1,4 +1,6 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from "node:fs"; +import { getStoredCredentials, updateStoredTokens, listStoredCredentials } from "./store.js"; +import { getDatabase } from "../db/index.js"; import { dirname, join, basename } from "node:path"; import { homedir } from "node:os"; import { createHash } from "node:crypto"; @@ -144,6 +146,34 @@ function buildAccountLabels(accounts: ClaudeAccount[]): void { } } +/** + * 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[] = []; @@ -194,10 +224,22 @@ export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredent return cached.creds; } - // Re-read from file to pick up external updates - const onDisk = readCredentialsFile(account.source); - if (onDisk) { - account.credentials = onDisk; + // 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) { @@ -222,10 +264,22 @@ export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Pr return cached.creds; } - // Re-read from file - const onDisk = readCredentialsFile(account.source); - if (onDisk) { - account.credentials = onDisk; + // 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) { @@ -238,7 +292,12 @@ export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Pr const refreshed = await refreshViaOAuth(account.credentials.refreshToken); if (refreshed && refreshed.expiresAt > now + 60_000) { account.credentials = refreshed; - writeCredentialsFile(account.source, 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; } @@ -458,15 +517,46 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport } } -const usageCacheMap = new Map<string, ClaudeUsageReport>(); +function getCachedUsage(keyId: string): ClaudeUsageReport | null { + try { + const db = getDatabase(); + const row = db.query( + "SELECT report_json FROM usage_cache WHERE key_id = $keyId", + ).get({ $keyId: keyId }) as { report_json: string } | null; + if (!row) return null; + return JSON.parse(row.report_json) as ClaudeUsageReport; + } 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 + } +} export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> { const creds = await refreshAccountCredentialsAsync(account); - if (!creds) return usageCacheMap.get(account.id) ?? null; + if (!creds) return getCachedUsage(account.id); const report = await fetchClaudeUsage(creds.accessToken); if (report) { - usageCacheMap.set(account.id, report); + setCachedUsage(account.id, "anthropic", report); return report; } - return usageCacheMap.get(account.id) ?? null; + return getCachedUsage(account.id); }
\ No newline at end of file diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index 0ae4edb..8cbedd9 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -7,6 +7,7 @@ export { type ClaudeUsageBucket, type ClaudeUsageReport, discoverClaudeAccounts, + getClaudeAccountsFromDB, fetchAnthropicModels, getAccountUsage, getAnthropicBetas, @@ -25,3 +26,19 @@ export { type OpencodeUsageBucket, type OpencodeUsageReport, } from "./opencode.js"; +export { + type StoredCredential, + importCredentialsFromFile, + getStoredCredentials, + updateStoredTokens, + deleteStoredCredentials, + listStoredCredentials, +} from "./store.js"; +export { + type StoredApiKey, + setApiKey, + getApiKey, + resolveApiKey, + deleteApiKey, + listApiKeys, +} from "./api-keys.js"; diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts index 7a74486..b8a9d4d 100644 --- a/packages/core/src/credentials/opencode.ts +++ b/packages/core/src/credentials/opencode.ts @@ -1,3 +1,5 @@ +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. @@ -16,14 +18,14 @@ export interface OpencodeUsageReport { } function getWorkspaceId(keyId: string): string | undefined { - // Match ai-usage convention: opencode-1 → OPENCODE_WS1_ID, opencode-2 → OPENCODE_WS2_ID + // 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 = process.env[`OPENCODE_WS${num}_ID`]; + const specific = resolveApiKey(`opencode-ws${num}`); if (specific) return specific; } - return process.env.OPENCODE_WS_ID; + return resolveApiKey("opencode-ws") ?? undefined; } function parseOcDouble(html: string, key: string): number { @@ -71,7 +73,7 @@ function parseOcBucket( export async function fetchOpencodeUsage( keyId: string, ): Promise<OpencodeUsageReport | null> { - const cookie = process.env.OPENCODE_COOKIE; + const cookie = resolveApiKey("opencode-cookie"); const wsId = getWorkspaceId(keyId); if (!cookie || !wsId) { diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts new file mode 100644 index 0000000..6c814f9 --- /dev/null +++ b/packages/core/src/credentials/store.ts @@ -0,0 +1,177 @@ +import { getDatabase } from "../db/index.js"; +import type { ClaudeCredentials } from "./claude.js"; +import { existsSync, readFileSync } from "node:fs"; + +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, + })); +} diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts new file mode 100644 index 0000000..818aff0 --- /dev/null +++ b/packages/core/src/db/index.ts @@ -0,0 +1,93 @@ +import { Database } from "bun:sqlite"; +import { existsSync, mkdirSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { homedir } from "node:os"; + +/** + * Returns the directory for persistent Dispatch data, following XDG Base + * Directory spec on Linux: `$XDG_DATA_HOME/dispatch` (defaults to + * `~/.local/share/dispatch`). + */ +function getDataDir(): string { + const xdg = process.env.XDG_DATA_HOME; + const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "share"); + return join(base, "dispatch"); +} + +let _db: Database | null = null; + +/** + * Get (or create) the singleton SQLite database. + * + * - Creates the data directory if it doesn't exist. + * - Creates `dispatch.db` if it doesn't exist. + * - Enables WAL journal mode for concurrent read performance. + */ +export function getDatabase(): Database { + if (_db) return _db; + + const dir = getDataDir(); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const dbPath = join(dir, "dispatch.db"); + _db = new Database(dbPath, { create: true }); + + // WAL mode: better concurrent read performance, safe for single-writer + _db.run("PRAGMA journal_mode = WAL;"); + // Recommended for WAL: normal synchronous is safe and faster + _db.run("PRAGMA synchronous = NORMAL;"); + // Enable foreign keys + _db.run("PRAGMA foreign_keys = ON;"); + + // Create tables + _db.run(`CREATE TABLE IF NOT EXISTS credentials ( + key_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at INTEGER NOT NULL, + subscription_type TEXT, + source_file TEXT, + imported_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`); + + _db.run(`CREATE TABLE IF NOT EXISTS wake_schedule ( + hour INTEGER PRIMARY KEY CHECK (hour BETWEEN 0 AND 23), + next_wake_at INTEGER NOT NULL + )`); + + _db.run(`CREATE TABLE IF NOT EXISTS usage_cache ( + key_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + cached_at INTEGER NOT NULL, + report_json TEXT NOT NULL + )`); + + _db.run(`CREATE TABLE IF NOT EXISTS api_keys ( + key_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + api_key TEXT NOT NULL, + imported_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`); + + return _db; +} + +/** Close the database connection (e.g. on shutdown). */ +export function closeDatabase(): void { + if (_db) { + _db.close(); + _db = null; + } +} + +/** Returns the path where the database file lives (or will live). */ +export function getDatabasePath(): string { + if (_db) return _db.filename; + const dir = getDataDir(); + return join(dir, "dispatch.db"); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae826dc..90cafbe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,3 +29,6 @@ export { ModelRegistry, ModelResolver } from "./models/index.js"; // Credentials export * from "./credentials/index.js"; + +// Database +export { getDatabase, closeDatabase, getDatabasePath } from "./db/index.js"; diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte index a2b735e..f5f7d6d 100644 --- a/packages/frontend/src/lib/components/KeyUsage.svelte +++ b/packages/frontend/src/lib/components/KeyUsage.svelte @@ -17,32 +17,8 @@ loading: boolean; } - // localStorage-backed cache: survives page refreshes - const CACHE_STORAGE_KEY = "dispatch-key-usage-cache"; - - function loadPersistedCache(): Map<string, KeyUsageData> { - try { - const raw = localStorage.getItem(CACHE_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw) as Record<string, KeyUsageData>; - return new Map(Object.entries(parsed)); - } - } catch { - // Ignore parse errors - } - return new Map(); - } - - function persistCache(cache: Map<string, KeyUsageData>): void { - try { - const obj = Object.fromEntries(cache.entries()); - localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(obj)); - } catch { - // Ignore storage errors (e.g. quota exceeded) - } - } - - const usageCache = loadPersistedCache(); + // In-memory cache for the current session (backend DB is the persistent cache) + const usageCache = new Map<string, KeyUsageData>(); function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] { return keyList.map((k) => { @@ -73,7 +49,6 @@ } else { const fresh = await res.json() as KeyUsageData; usageCache.set(key.id, fresh); - persistCache(usageCache); updateEntry(key.id, { data: fresh, error: null, @@ -206,6 +181,18 @@ }).join(" "); } + // Cycle durations in ms + const FIVE_HOUR_MS = 5 * 60 * 60 * 1000; + const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000; + const THIRTY_DAY_MS = 30 * 24 * 60 * 60 * 1000; + + function cycleElapsedPct(resetsAt: number | undefined, cycleDurationMs: number): number { + if (!resetsAt) return -1; + const timeRemaining = resetsAt - Date.now(); + const elapsed = cycleDurationMs - timeRemaining; + return Math.max(0, Math.min(100, Math.round((elapsed / cycleDurationMs) * 100))); + } + function hasBucketData(bucket: UsageBucket | undefined): boolean { return bucket !== undefined && bucket.utilization !== undefined; } @@ -255,34 +242,46 @@ {@const b = acct.fiveHour!} {@const u = b.utilization ?? 0} {@const p = Math.round(u * 100)} + {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)} <div class="flex flex-col gap-0.5"> <div class="flex items-center justify-between"> <span class="text-xs text-base-content/50">5-Hour</span> <span class="text-xs font-mono">{p}%</span> </div> - <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress> - {#if b.resetsAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> - {/if} - </div> - {/if} - {#if hasBucketData(acct.sevenDay)} + <div class="relative w-full h-2"> + <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + {#if tp >= 0} + <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> + {/if} + </div> + {#if b.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> + {/if} + </div> + {/if} + {#if hasBucketData(acct.sevenDay)} {@const b = acct.sevenDay!} {@const u = b.utilization ?? 0} {@const p = Math.round(u * 100)} + {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)} <div class="flex flex-col gap-0.5"> <div class="flex items-center justify-between"> <span class="text-xs text-base-content/50">Weekly</span> <span class="text-xs font-mono">{p}%</span> </div> - <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress> - {#if b.resetsAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> - {/if} - </div> - {/if} - </div> - {/each} + <div class="relative w-full h-2"> + <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + {#if tp >= 0} + <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> + {/if} + </div> + {#if b.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> + {/if} + </div> + {/if} + </div> + {/each} {/if} </div> {/if} @@ -323,48 +322,66 @@ {@const b = entry.data.fiveHour!} {@const u = b.utilization ?? 0} {@const p = Math.round(u * 100)} + {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)} <div class="flex flex-col gap-0.5"> <div class="flex items-center justify-between"> <span class="text-xs text-base-content/50">5-Hour</span> <span class="text-xs font-mono">{p}%</span> </div> - <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress> - {#if b.resetsAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> - {/if} - </div> - {/if} - {#if hasBucketData(entry.data.weekly)} + <div class="relative w-full h-2"> + <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + {#if tp >= 0} + <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> + {/if} + </div> + {#if b.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> + {/if} + </div> + {/if} + {#if hasBucketData(entry.data.weekly)} {@const b = entry.data.weekly!} {@const u = b.utilization ?? 0} {@const p = Math.round(u * 100)} + {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)} <div class="flex flex-col gap-0.5"> <div class="flex items-center justify-between"> <span class="text-xs text-base-content/50">Weekly</span> <span class="text-xs font-mono">{p}%</span> </div> - <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress> - {#if b.resetsAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> - {/if} - </div> - {/if} - {#if hasBucketData(entry.data.monthly)} + <div class="relative w-full h-2"> + <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + {#if tp >= 0} + <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> + {/if} + </div> + {#if b.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> + {/if} + </div> + {/if} + {#if hasBucketData(entry.data.monthly)} {@const b = entry.data.monthly!} {@const u = b.utilization ?? 0} {@const p = Math.round(u * 100)} + {@const tp = cycleElapsedPct(b.resetsAt, THIRTY_DAY_MS)} <div class="flex flex-col gap-0.5"> <div class="flex items-center justify-between"> <span class="text-xs text-base-content/50">Monthly</span> <span class="text-xs font-mono">{p}%</span> </div> - <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress> - {#if b.resetsAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> - {/if} - </div> - {/if} + <div class="relative w-full h-2"> + <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + {#if tp >= 0} + <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> + {/if} + </div> + {#if b.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> + {/if} + </div> {/if} + {/if} {:else if entry.data.provider === "github-copilot"} {@const p = Math.round(entry.data.percentUsed ?? 0)} diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte index 34a0563..b2b6902 100644 --- a/packages/frontend/src/lib/components/ModelStatus.svelte +++ b/packages/frontend/src/lib/components/ModelStatus.svelte @@ -13,16 +13,27 @@ tags: string[]; } + interface CredentialStatus { + keyId: string; + provider: string; + subscriptionType: string | null; + importedAt: number; + updatedAt: number; + expired: boolean; + } + const { models = [], keys = [], tags = [], currentModel, + apiBase = "", }: { models?: ModelInfo[]; keys?: KeyInfo[]; tags?: string[]; currentModel?: string; + apiBase?: string; } = $props(); const activeKeys = $derived(keys.filter((k) => k.status === "active").length); @@ -33,6 +44,102 @@ const uniqueTags = $derived([...new Set(tags)]); + let credentialStatus = $state<Record<string, CredentialStatus>>({}); + let importingKey = $state<string | null>(null); + let importError = $state<string | null>(null); + let importSuccess = $state<string | null>(null); + + let apiKeyStatus = $state<Record<string, { keyId: string; provider: string; importedAt: number; updatedAt: number }>>({}); + let showKeyModal = $state<string | null>(null); // keyId or null + let keyModalValue = $state(""); + let keyModalError = $state<string | null>(null); + let keyModalSaving = $state(false); + + async function loadCredentialStatus(): Promise<void> { + try { + const res = await fetch(`${apiBase}/models/credentials-status`); + if (!res.ok) return; + const data = await res.json() as { credentials: CredentialStatus[] }; + const map: Record<string, CredentialStatus> = {}; + for (const cred of data.credentials) { + map[cred.keyId] = cred; + } + credentialStatus = map; + } catch { + // ignore + } + } + + async function importCredentials(keyId: string): Promise<void> { + importingKey = keyId; + importError = null; + importSuccess = null; + try { + const res = await fetch(`${apiBase}/models/import-credentials`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyId }), + }); + const data = await res.json() as { success?: boolean; error?: string }; + if (!res.ok || !data.success) { + importError = data.error ?? "Import failed"; + } else { + importSuccess = keyId; + await loadCredentialStatus(); + setTimeout(() => { importSuccess = null; }, 3000); + } + } catch (e) { + importError = e instanceof Error ? e.message : "Network error"; + } finally { + importingKey = null; + } + } + + async function loadApiKeyStatus(): Promise<void> { + try { + const res = await fetch(`${apiBase}/models/api-keys-status`); + if (!res.ok) return; + const data = await res.json() as { keys: Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }> }; + const map: Record<string, typeof data.keys[0]> = {}; + for (const k of data.keys) { + map[k.keyId] = k; + } + apiKeyStatus = map; + } catch { + // ignore + } + } + + async function saveApiKey(): Promise<void> { + if (!showKeyModal || !keyModalValue.trim()) return; + keyModalSaving = true; + keyModalError = null; + try { + const res = await fetch(`${apiBase}/models/set-api-key`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyId: showKeyModal, apiKey: keyModalValue.trim() }), + }); + const data = await res.json() as { success?: boolean; error?: string }; + if (!res.ok || !data.success) { + keyModalError = data.error ?? "Failed to save"; + } else { + showKeyModal = null; + keyModalValue = ""; + await loadApiKeyStatus(); + } + } catch (e) { + keyModalError = e instanceof Error ? e.message : "Network error"; + } finally { + keyModalSaving = false; + } + } + + $effect(() => { + void loadCredentialStatus(); + void loadApiKeyStatus(); + }); + function timeAgo(ts: number | null): string { if (ts === null) return ""; const diffMs = Date.now() - ts; @@ -46,7 +153,7 @@ function truncate(str: string | null, max: number): string { if (!str) return ""; - return str.length > max ? str.slice(0, max) + "…" : str; + return str.length > max ? str.slice(0, max) + "..." : str; } </script> @@ -96,12 +203,21 @@ </div> {/if} + <!-- Import error/success banners --> + {#if importError} + <div role="alert" class="text-xs text-error/80">{importError}</div> + {/if} + {#if importSuccess} + <div class="text-xs text-success/80">Imported credentials for {importSuccess}</div> + {/if} + <!-- Keys --> {#if keys.length > 0} <div class="flex flex-col gap-1"> <p class="text-xs text-base-content/50 uppercase tracking-wide">API Keys</p> <ul class="flex flex-col gap-1"> {#each keys as key (key.id)} + {@const cred = credentialStatus[key.id]} <li class="flex flex-col gap-0.5 rounded p-1 hover:bg-base-200 transition-colors"> <div class="flex items-center gap-1.5"> <span @@ -126,10 +242,88 @@ {/if} </div> {/if} - </li> - {/each} + <!-- Credential import for anthropic keys --> + {#if key.provider === "anthropic"} + <div class="pl-2 flex items-center gap-1.5 mt-0.5"> + {#if cred} + <span class="badge badge-xs {cred.expired ? 'badge-warning' : 'badge-info'}"> + {cred.expired ? "expired" : "imported"} + </span> + {#if cred.subscriptionType} + <span class="text-xs text-base-content/40">{cred.subscriptionType}</span> + {/if} + {/if} + <button + type="button" + class="btn btn-xs btn-ghost btn-outline" + disabled={importingKey === key.id} + onclick={() => importCredentials(key.id)} + > + {#if importingKey === key.id} + <span class="loading loading-spinner loading-xs"></span> + {:else} + {cred ? "Re-import" : "Import Credentials"} + {/if} + </button> + </div> + {/if} + <!-- API key import for env-based keys (opencode, copilot, etc) --> + {#if key.provider !== "anthropic"} + <div class="pl-2 flex items-center gap-1.5 mt-0.5"> + {#if apiKeyStatus[key.id]} + <span class="badge badge-xs badge-info">imported</span> + {/if} + <button + type="button" + class="btn btn-xs btn-ghost btn-outline" + onclick={() => { showKeyModal = key.id; keyModalValue = ""; keyModalError = null; }} + > + {apiKeyStatus[key.id] ? "Update Key" : "Import Key"} + </button> + </div> + {/if} + </li> + {/each} </ul> </div> {/if} {/if} +<!-- Import Key Modal --> +{#if showKeyModal} + <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog"> + <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl"> + <h3 class="text-sm font-semibold">Import Key: {showKeyModal}</h3> + <input + type="password" + class="input input-bordered input-sm w-full" + placeholder="Paste API key..." + bind:value={keyModalValue} + /> + {#if keyModalError} + <p class="text-xs text-error">{keyModalError}</p> + {/if} + <div class="flex justify-end gap-2"> + <button + type="button" + class="btn btn-sm btn-ghost" + onclick={() => { showKeyModal = null; keyModalValue = ""; keyModalError = null; }} + > + Cancel + </button> + <button + type="button" + class="btn btn-sm btn-primary" + disabled={!keyModalValue.trim() || keyModalSaving} + onclick={saveApiKey} + > + {#if keyModalSaving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Save + {/if} + </button> + </div> + </div> + </div> +{/if} </div> diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index 6edbdbf..79eb73b 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -109,7 +109,7 @@ {:else if panel.selected === "Claude Reset"} <ClaudeReset {apiBase} /> {:else if panel.selected === "Model Status"} - <ModelStatus {models} {keys} {tags} /> + <ModelStatus {models} {keys} {tags} {apiBase} /> {:else if panel.selected === "Tasks"} <TaskListPanel {tasks} /> {:else if panel.selected === "Config"} |
