import type { ModelRegistry, ModelResolver } from "@dispatch/core"; import { ANTHROPIC_MODELS_FALLBACK, type ClaudeAccount, discoverClaudeAccounts, fetchAnthropicModels, fetchCopilotUsage, fetchOpencodeUsage, getAccountUsage, validateAccountCredentials, } from "@dispatch/core"; import { Hono } from "hono"; let getRegistry: () => ModelRegistry | null = () => null; let getResolver: () => ModelResolver | null = () => null; let getAccounts: () => ClaudeAccount[] = () => []; export function setModelsGetter( registryGetter: () => ModelRegistry | null, resolverGetter: () => ModelResolver | null, ): void { getRegistry = registryGetter; getResolver = resolverGetter; } export function setAccountsGetter(getter: () => ClaudeAccount[]): void { getAccounts = getter; } export const modelsRoutes = new Hono(); modelsRoutes.get("/", (c) => { const registry = getRegistry(); if (!registry) { return c.json({ models: [], tags: [], keys: [] }); } const models = registry.getModels(); const tags = registry.getAllTags(); const keyStates = registry.getKeys(); const keys = keyStates.map((ks) => ({ id: ks.definition.id, provider: ks.definition.provider, status: ks.status, lastError: ks.lastError ?? null, exhaustedAt: ks.exhaustedAt ?? null, })); return c.json({ models, tags, keys }); }); modelsRoutes.get("/resolve", (c) => { const registry = getRegistry(); const resolver = getResolver(); if (!registry || !resolver) { return c.json({ resolved: null, reason: "no models configured" }); } const tag = c.req.query("tag"); if (!tag) { return c.json({ error: "tag query parameter is required" }, 400); } const matchingModels = registry.getModelsByTag(tag); if (matchingModels.length === 0) { return c.json({ resolved: null, reason: "no models match tag" }); } const resolved = resolver.resolve(tag); if (!resolved) { return c.json({ resolved: null, reason: "all keys exhausted for matching providers" }); } return c.json({ resolved: { model: resolved.model, key: { id: resolved.key.id, provider: resolved.key.provider, }, }, }); }); // Fetch available models for a specific provider key. modelsRoutes.get("/available", async (c) => { const registry = getRegistry(); if (!registry) { return c.json({ error: "no registry configured" }, 500); } const keyId = c.req.query("keyId"); if (!keyId) { return c.json({ error: "keyId query parameter is required" }, 400); } const keyStates = registry.getKeys(); const key = keyStates.find((ks) => ks.definition.id === keyId); if (!key) { return c.json({ error: `key not found: ${keyId}` }, 404); } // 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]; if (!account) { return c.json({ error: "no Claude credentials found" }, 500); } const profile = await validateAccountCredentials(account); if (!profile) { return c.json( { error: "Claude credentials are invalid or expired", details: "Run `claude` to re-authenticate.", }, 401, ); } const creds = account.credentials; let models = await fetchAnthropicModels(creds.accessToken); if (models.length === 0) { models = ANTHROPIC_MODELS_FALLBACK; } return c.json({ models, subscriptionType: account.credentials.subscriptionType, ...(profile.email ? { email: profile.email } : {}), }); } const apiKeyValue = key.definition.env ? process.env[key.definition.env] : undefined; if (!apiKeyValue) { return c.json({ error: `env var not set: ${key.definition.env}` }, 500); } const baseUrl = key.definition.base_url.replace(/\/+$/, ""); const url = `${baseUrl}/models`; const headers: Record = { Authorization: `Bearer ${apiKeyValue}`, }; if (key.definition.provider === "github-copilot") { headers["Copilot-Integration-Id"] = "vscode-chat"; } let response: Response; try { response = await fetch(url, { headers }); } catch (err) { return c.json({ error: "provider API call failed", details: String(err) }, 502); } if (!response.ok) { const text = await response.text().catch(() => ""); return c.json( { error: "provider API returned error", status: response.status, details: text }, 502, ); } let data: { data: { id: string }[] }; try { data = await response.json(); } catch (err) { return c.json({ error: "failed to parse provider response", details: String(err) }, 502); } const models = data.data.map((m) => m.id); return c.json({ models }); }); // List available Claude accounts with validated credentials modelsRoutes.get("/claude-accounts", async (c) => { const candidates = discoverClaudeAccounts(); // Validate each account's credentials; only include ones with a working token const validated: Array<{ id: string; label: string; source: string; subscriptionType: string; expiresAt: number; email?: string; }> = []; for (const acct of candidates) { const profile = await validateAccountCredentials(acct); if (profile) { validated.push({ id: acct.id, label: acct.label, source: acct.source, subscriptionType: acct.credentials.subscriptionType ?? "unknown", expiresAt: acct.credentials.expiresAt, ...(profile.email ? { email: profile.email } : {}), }); } } return c.json({ accounts: validated }); }); // Get usage for a specific Claude account modelsRoutes.get("/claude-usage", async (c) => { const accountId = c.req.query("accountId"); const accounts = getAccounts(); const accountAccounts = discoverClaudeAccounts(); const allAccounts = accounts.length > 0 ? accounts : accountAccounts; let account: ClaudeAccount | undefined; if (accountId) { account = allAccounts.find((a) => a.id === accountId); if (!account) { return c.json({ error: `account not found: ${accountId}` }, 404); } } else { account = allAccounts[0]; } if (!account) { return c.json({ error: "no Claude accounts available" }, 404); } const report = await getAccountUsage(account); if (!report) { return c.json({ error: "failed to fetch usage data" }, 502); } return c.json(report); }); // Get usage for a specific key by ID modelsRoutes.get("/key-usage", async (c) => { const keyId = c.req.query("keyId"); if (!keyId) { return c.json({ error: "keyId query parameter 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); } const provider = key.definition.provider; try { if (provider === "anthropic") { const accounts = discoverClaudeAccounts(); if (accounts.length === 0) { return c.json({ error: "no Claude accounts available" }, 502); } // Fetch usage for all discovered accounts const accountResults = await Promise.all( accounts.map(async (acct) => { const report = await getAccountUsage(acct); return { label: acct.label, source: acct.source, subscriptionType: acct.credentials.subscriptionType, fiveHour: report?.fiveHour, sevenDay: report?.sevenDay, error: report ? undefined : "failed to fetch", }; }), ); return c.json({ provider: "anthropic", accounts: accountResults, // Legacy single-account fields (first account) fiveHour: accountResults[0]?.fiveHour, sevenDay: accountResults[0]?.sevenDay, }); } else if (provider === "opencode-go") { // Cookie-based HTML scraper. Uses OPENCODE_COOKIE env var plus // OPENCODE_WS1_ID / OPENCODE_WS2_ID (keyed by the key's numeric suffix). const report = await fetchOpencodeUsage(key.definition.id); if (report) { return c.json({ provider: "opencode-go", fiveHour: report.fiveHour, weekly: report.weekly, monthly: report.monthly, }); } // Fall back: show limits info with link to console return c.json({ provider: "opencode-go", unavailable: true, consoleUrl: "https://opencode.ai/auth", limits: { fiveHour: "$12", weekly: "$30", monthly: "$60", }, }); } 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]; if (!token) { return c.json({ error: `env var ${key.definition.env} not set` }, 502); } const report = await fetchCopilotUsage(token, key.definition.base_url); if (!report) { return c.json({ error: "failed to fetch usage data" }, 502); } return c.json({ provider: "github-copilot", tokensConsumed: report.tokensConsumed, tokensRemaining: report.tokensRemaining, percentUsed: report.percentUsed, resetAt: report.resetAt, plan: report.plan, }); } else { return c.json({ error: "usage tracking not supported for this provider" }, 400); } } catch (err) { const message = err instanceof Error ? err.message : String(err); return c.json({ error: `failed to fetch usage: ${message}` }, 502); } });