summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 20:40:35 +0900
committerAdam Malczewski <[email protected]>2026-05-20 20:40:35 +0900
commit8151447758e6826a578363758a755c6cebd1c05f (patch)
tree6afa780c28ca6e4622c1ab30238665caaad4371e /packages/api/src
parentf05099d450748cc7508f8cbde4e6539db2105f6d (diff)
downloaddispatch-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.ts176
-rw-r--r--packages/api/src/app.ts11
-rw-r--r--packages/api/src/routes/models.ts158
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