diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 20:40:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 20:40:35 +0900 |
| commit | 8151447758e6826a578363758a755c6cebd1c05f (patch) | |
| tree | 6afa780c28ca6e4622c1ab30238665caaad4371e /packages/api/src | |
| parent | f05099d450748cc7508f8cbde4e6539db2105f6d (diff) | |
| download | dispatch-8151447758e6826a578363758a755c6cebd1c05f.tar.gz dispatch-8151447758e6826a578363758a755c6cebd1c05f.zip | |
feat: claude max oauth support with multi-account switching, reasoning effort, and dynamic model listing
Diffstat (limited to 'packages/api/src')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 176 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 11 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 158 |
3 files changed, 328 insertions, 17 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 28b54f5..4f45781 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -19,11 +19,15 @@ import { ModelResolver, TaskList, createTaskListTool, + type ClaudeAccount, + discoverClaudeAccounts, + refreshAccountCredentials, + refreshAccountCredentialsAsync, } from "@dispatch/core"; import type { PermissionManager } from "./permission-manager.js"; import { setConfigGetter } from "./routes/config.js"; import { setSkillsGetter } from "./routes/skills.js"; -import { setModelsGetter } from "./routes/models.js"; +import { setModelsGetter, setAccountsGetter } from "./routes/models.js"; const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have access to the following tools for working with files in the current working directory: @@ -42,6 +46,9 @@ export class AgentManager { private eventListeners: Set<(event: AgentEvent) => void> = new Set(); private permissionManager: PermissionManager | undefined; + activeModelId: string | null = null; + activeKeyId: string | null = null; + private config: DispatchConfig; private skillsData: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }; private modelRegistry: ModelRegistry | null = null; @@ -51,6 +58,8 @@ export class AgentManager { private configWatcher: { close(): void } | null = null; private skillsWatcher: { close(): void } | null = null; + private claudeAccounts: ClaudeAccount[] = []; + constructor(permissionManager?: PermissionManager) { this.permissionManager = permissionManager; @@ -71,6 +80,9 @@ export class AgentManager { // Load initial skills this.skillsData = loadSkills(workingDirectory); + // Discover Claude accounts + this._refreshClaudeAccounts(); + // Wire route getters setConfigGetter(() => this.config); setSkillsGetter(() => this.skillsData); @@ -78,6 +90,7 @@ export class AgentManager { () => this.modelRegistry, () => this.modelResolver, ); + setAccountsGetter(() => this.claudeAccounts); // Set up task list this.taskList = new TaskList(); @@ -109,6 +122,17 @@ export class AgentManager { }); } + private _refreshClaudeAccounts(): void { + try { + this.claudeAccounts = discoverClaudeAccounts(); + if (this.claudeAccounts.length > 0) { + console.log(`dispatch: discovered ${this.claudeAccounts.length} Claude account(s)`); + } + } catch (err) { + console.warn(`dispatch: failed to discover Claude accounts: ${err instanceof Error ? err.message : String(err)}`); + } + } + private _initModelRegistry(config: DispatchConfig): void { if (config.models && config.keys) { if (this.modelRegistry) { @@ -132,7 +156,23 @@ export class AgentManager { return this.taskList; } - private getOrCreateAgent(): Agent { + getClaudeAccounts(): ClaudeAccount[] { + return this.claudeAccounts; + } + + private async getOrCreateAgent(keyId?: string, modelId?: string): Promise<Agent> { + // Determine effective override: use provided values, or fall back to stored active values + const effectiveKeyId = keyId ?? this.activeKeyId ?? undefined; + const effectiveModelId = modelId ?? this.activeModelId ?? undefined; + + // If the override differs from what the current agent was built with, invalidate the cache + if ( + this.agent && + (effectiveKeyId !== this.activeKeyId || effectiveModelId !== this.activeModelId) + ) { + this.agent = null; + } + if (!this.agent) { const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd(); @@ -150,8 +190,90 @@ export class AgentManager { let apiKey = process.env.OPENCODE_API_KEY ?? ""; let model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash"; let baseURL = "https://opencode.ai/zen/go/v1"; + let provider: string | undefined; + let claudeCredentials: { accessToken: string } | undefined; + + let useOverride = false; + + if (effectiveKeyId && effectiveModelId && this.modelRegistry) { + // Direct override: look up the key by id in the registry + const keyState = this.modelRegistry.getKeys().find((k) => k.definition.id === effectiveKeyId); + if (keyState) { + const key = keyState.definition; + 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]; + if (account) { + const creds = refreshAccountCredentials(account); + if (creds && creds.expiresAt > Date.now() + 60_000) { + claudeCredentials = { accessToken: creds.accessToken }; + apiKey = creds.accessToken; + baseURL = key.base_url; + model = effectiveModelId; + provider = "anthropic"; + this.activeKeyId = effectiveKeyId; + this.activeModelId = effectiveModelId; + useOverride = true; + } else { + // Token expired — await the async refresh + const fresh = await refreshAccountCredentialsAsync(account); + if (fresh && fresh.expiresAt > Date.now() + 60_000) { + account.credentials = fresh; + claudeCredentials = { accessToken: fresh.accessToken }; + apiKey = fresh.accessToken; + baseURL = key.base_url; + model = effectiveModelId; + provider = "anthropic"; + this.activeKeyId = effectiveKeyId; + this.activeModelId = effectiveModelId; + useOverride = true; + } else { + console.warn(`dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`); + claudeCredentials = { accessToken: account.credentials.accessToken }; + apiKey = account.credentials.accessToken; + baseURL = key.base_url; + model = effectiveModelId; + provider = "anthropic"; + this.activeKeyId = effectiveKeyId; + this.activeModelId = effectiveModelId; + useOverride = true; + } + } + } else { + console.warn(`dispatch: no Claude credentials found for key "${key.id}"`); + } + } else { + // Standard key: resolve from env var + const envKey = key.env ? process.env[key.env] : undefined; + if (envKey) { + apiKey = envKey; + baseURL = key.base_url; + model = effectiveModelId; + this.activeKeyId = effectiveKeyId; + this.activeModelId = effectiveModelId; + useOverride = true; + } else { + console.warn(`dispatch: env var "${key.env}" not set for key "${key.id}", falling back to env vars`); + this.activeKeyId = effectiveKeyId; + this.activeModelId = effectiveModelId; + useOverride = true; + } + } + } else { + console.warn(`dispatch: key "${effectiveKeyId}" not found in model registry, falling back to tag-based resolution`); + } + } - if (this.modelRegistry && this.modelResolver) { + if (!useOverride) { + // Clear any previous override when falling back to default resolution + this.activeKeyId = null; + this.activeModelId = null; + } + + if (!useOverride && this.modelRegistry && this.modelResolver) { // Try to get model_tag from default agent template, fall back to "heavy" const defaultAgent = this.config.agents?.["default"]; const tag = defaultAgent?.model_tag ?? "heavy"; @@ -159,15 +281,38 @@ export class AgentManager { if (resolved) { model = resolved.model.id; baseURL = resolved.key.base_url; - const envKey = process.env[resolved.key.env]; - if (envKey) { - apiKey = envKey; + // 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]; + if (account) { + let creds = refreshAccountCredentials(account); + if (!creds || creds.expiresAt <= Date.now() + 60_000) { + creds = await refreshAccountCredentialsAsync(account); + if (creds) account.credentials = creds; + } + if (creds) { + claudeCredentials = { accessToken: creds.accessToken }; + apiKey = creds.accessToken; + provider = "anthropic"; + } else { + console.warn(`dispatch: no valid Claude credentials for key "${resolved.key.id}"`); + } + } else { + console.warn(`dispatch: no Claude credentials found for key "${resolved.key.id}"`); + } } else { - console.warn(`dispatch: env var "${resolved.key.env}" not set for key "${resolved.key.id}", falling back to env vars`); - // Don't use the resolved key — fall back to default env vars entirely - model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash"; - baseURL = "https://opencode.ai/zen/go/v1"; - apiKey = process.env.OPENCODE_API_KEY ?? ""; + const envKey = process.env[resolved.key.env!]; + 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"; + baseURL = "https://opencode.ai/zen/go/v1"; + apiKey = process.env.OPENCODE_API_KEY ?? ""; + } } } else { console.warn(`dispatch: could not resolve model for tag "${tag}", falling back to env vars`); @@ -183,6 +328,8 @@ export class AgentManager { workingDirectory, permissionChecker: this.permissionManager ?? undefined, ruleset, + provider, + ...(claudeCredentials ? { claudeCredentials } : {}), }); } return this.agent; @@ -209,14 +356,13 @@ export class AgentManager { } } - async processMessage(message: string): Promise<void> { - const agent = this.getOrCreateAgent(); - + async processMessage(message: string, keyId?: string, modelId?: string, reasoningEffort?: "none" | "low" | "medium" | "high" | "max"): Promise<void> { this.status = "running"; this.messageCount += 1; try { - for await (const event of agent.run(message)) { + const agent = await this.getOrCreateAgent(keyId, modelId); + for await (const event of agent.run(message, reasoningEffort ? { reasoningEffort } : undefined)) { this.status = event.type === "status" ? event.status : this.status; this.emit(event); } diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index c2fbae5..3591281 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -33,7 +33,7 @@ app.get("/status", (c) => { }); app.post("/chat", async (c) => { - const body = await c.req.json<{ message?: unknown }>(); + const body = await c.req.json<{ message?: unknown; keyId?: unknown; modelId?: unknown; reasoningEffort?: unknown }>(); const message = body.message; if (typeof message !== "string" || message.trim() === "") { @@ -44,8 +44,15 @@ app.post("/chat", async (c) => { return c.json({ error: "agent is already running" }, 409); } + const keyId = typeof body.keyId === "string" ? body.keyId : undefined; + const modelId = typeof body.modelId === "string" ? body.modelId : undefined; + const validEfforts = ["none", "low", "medium", "high", "max"]; + const reasoningEffort = typeof body.reasoningEffort === "string" && validEfforts.includes(body.reasoningEffort) + ? (body.reasoningEffort as "none" | "low" | "medium" | "high" | "max") + : undefined; + // Non-blocking — let the agent run in the background - agentManager.processMessage(message).catch(console.error); + agentManager.processMessage(message, keyId, modelId, reasoningEffort).catch(console.error); return c.json({ status: "ok" }); }); diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 62f7340..2e002c9 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -1,8 +1,17 @@ import { Hono } from "hono"; import type { ModelRegistry, ModelResolver } from "@dispatch/core"; +import { + type ClaudeAccount, + discoverClaudeAccounts, + validateAccountCredentials, + fetchAnthropicModels, + ANTHROPIC_MODELS_FALLBACK, + getAccountUsage, +} from "@dispatch/core"; let getRegistry: () => ModelRegistry | null = () => null; let getResolver: () => ModelResolver | null = () => null; +let getAccounts: () => ClaudeAccount[] = () => []; export function setModelsGetter( registryGetter: () => ModelRegistry | null, @@ -12,6 +21,10 @@ export function setModelsGetter( getResolver = resolverGetter; } +export function setAccountsGetter(getter: () => ClaudeAccount[]): void { + getAccounts = getter; +} + export const modelsRoutes = new Hono(); modelsRoutes.get("/", (c) => { @@ -67,3 +80,148 @@ modelsRoutes.get("/resolve", (c) => { }, }); }); + +// 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<string, string> = { + 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); +});
\ No newline at end of file |
