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/agent-manager.ts | |
| 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/agent-manager.ts')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 176 |
1 files changed, 161 insertions, 15 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); } |
