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 | |
| 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
27 files changed, 1362 insertions, 128 deletions
@@ -7,3 +7,4 @@ build/ *.sqlite .DS_Store .skills/ +references/ @@ -4,4 +4,4 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" down "$@" +docker compose -f "$PROJECT_DIR/docker-compose.yml" down "$@" @@ -11,5 +11,5 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" OPENCODE_API_KEY="$(gopass show -o projects/ai-api/opencode_go_key)" # Start all services -sudo OPENCODE_API_KEY="$OPENCODE_API_KEY" \ +OPENCODE_API_KEY="$OPENCODE_API_KEY" \ docker compose -f "$PROJECT_DIR/docker-compose.yml" up "$@" @@ -25,6 +25,7 @@ "name": "@dispatch/core", "version": "0.0.1", "dependencies": { + "@ai-sdk/anthropic": "1.2.12", "@ai-sdk/openai-compatible": "^0.2.0", "ai": "^4.0.0", "chokidar": "^5.0.0", @@ -59,6 +60,8 @@ }, }, "packages": { + "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="], + "@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-LkvfcM8slJedRyJa/MiMiaOzcMjV1zNDwzTHEGz7aAsgsQV0maLfmJRi/nuSwf5jmp0EouC+JXXDUj2l94HgQw=="], "@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], diff --git a/dispatch.toml b/dispatch.toml index 1559578..407c7c4 100644 --- a/dispatch.toml +++ b/dispatch.toml @@ -2,15 +2,23 @@ # Keys reference env var names in .env.dispatch # ─── Fallback Order (highest priority first) ──────────────────── -# Exhaust opencode-1 first, then opencode-2, then copilot. +# 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 = ["opencode-1", "opencode-2", "copilot"] +fallback = ["claude-max", "opencode-1", "opencode-2", "copilot"] # ─── API Keys ─────────────────────────────────────────────────── [[keys]] +id = "claude-max" +provider = "anthropic" +base_url = "https://api.anthropic.com/v1" +# No env needed — credentials read from ~/.claude/.credentials.json +# Optional: specify a specific credentials file for multi-account: +# credentials_file = "/home/tradam/.claude/.credentials-2.json" + +[[keys]] id = "opencode-1" provider = "opencode-go" env = "OPENCODE_KEY_1" @@ -31,6 +39,16 @@ base_url = "https://api.githubcopilot.com" # ─── Models ───────────────────────────────────────────────────── [[models]] +id = "claude-sonnet-4-20250514" +provider = "anthropic" +tags = ["heavy", "coding", "review"] + +[[models]] +id = "claude-opus-4-20250514" +provider = "anthropic" +tags = ["heavy", "coding", "review"] + +[[models]] id = "deepseek-v4-pro" provider = "opencode-go" tags = ["heavy", "coding"] diff --git a/docker-compose.yml b/docker-compose.yml index f669705..7a67505 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - "3000:3000" volumes: - .:/app + - ~/.claude:/root/.claude env_file: - .env.dispatch environment: 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 diff --git a/packages/core/package.json b/packages/core/package.json index 4cb69f9..a9bf6e9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@ai-sdk/anthropic": "1.2.12", "@ai-sdk/openai-compatible": "^0.2.0", "ai": "^4.0.0", "chokidar": "^5.0.0", diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 2fd6746..006ee1b 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -2,9 +2,10 @@ import type { CoreMessage } from "ai"; import { streamText } from "ai"; import { dirname, isAbsolute, relative, resolve } from "node:path"; import { realpathSync } from "node:fs"; -import { createProvider } from "../llm/provider.js"; +import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js"; import { createToolRegistry } from "../tools/registry.js"; import { analyzeCommand } from "../tools/shell-analyze.js"; +import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js"; import type { AgentConfig, AgentEvent, @@ -14,7 +15,7 @@ import type { ToolResult, } from "../types/index.js"; -function toCoreMessages(messages: ChatMessage[]): CoreMessage[] { +function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMessage[] { const result: CoreMessage[] = []; for (const msg of messages) { if (msg.role === "user") { @@ -22,11 +23,13 @@ function toCoreMessages(messages: ChatMessage[]): CoreMessage[] { } else if (msg.role === "assistant") { const parts: Array<{ type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> }> = [{ type: "text", text: msg.content }]; for (const tc of msg.toolCalls ?? []) { - parts.push({ type: "tool-call", toolCallId: tc.id, toolName: tc.name, args: tc.arguments }); + const toolName = isAnthropic ? prefixToolName(tc.name) : tc.name; + parts.push({ type: "tool-call", toolCallId: tc.id, toolName, args: tc.arguments }); } result.push({ role: "assistant", content: parts }); for (const tr of msg.toolResults ?? []) { - result.push({ role: "tool", content: [{ type: "tool-result", toolCallId: tr.toolCallId, toolName: tr.toolName, result: tr.result }] }); + const toolName = isAnthropic ? prefixToolName(tr.toolName) : tr.toolName; + result.push({ role: "tool", content: [{ type: "tool-result", toolCallId: tr.toolCallId, toolName, result: tr.result }] }); } } } @@ -191,18 +194,36 @@ export class Agent { } } - async *run(userMessage: string): AsyncGenerator<AgentEvent> { + async *run(userMessage: string, options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" }): AsyncGenerator<AgentEvent> { this.status = "running"; yield { type: "status", status: "running" }; this.messages.push({ role: "user", content: userMessage }); const registry = createToolRegistry(this.config.tools); + const isAnthropic = this.config.provider === "anthropic"; const providerFactory = createProvider({ apiKey: this.config.apiKey, baseURL: this.config.baseURL, + provider: this.config.provider, + claudeCredentials: this.config.claudeCredentials, }); + // For Anthropic provider, prefix tool names and build full system prompt + const aiTools = registry.getAISDKTools(); + const tools = isAnthropic + ? Object.fromEntries( + Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]), + ) + : aiTools; + + // Build system prompt + let systemPrompt = this.config.systemPrompt; + if (isAnthropic) { + const billingHeader = buildBillingHeaderValue(this.messages); + systemPrompt = `${billingHeader}\n${SYSTEM_IDENTITY}\n\n${systemPrompt}`; + } + try { // Track the final assistant message across all steps let finalText = ""; @@ -214,15 +235,25 @@ export class Agent { const stepMessages: ChatMessage[] = [...this.messages]; for (let step = 0; step < MAX_STEPS; step++) { - const result = streamText({ + const effort = options?.reasoningEffort ?? this.config.reasoningEffort ?? "max"; + + // Build stream text options + const streamOptions: Parameters<typeof streamText>[0] = { model: providerFactory(this.config.model), - system: this.config.systemPrompt, - messages: toCoreMessages(stepMessages), - tools: registry.getAISDKTools(), - providerOptions: { - openaiCompatible: { reasoningEffort: "max" }, - }, - }); + system: systemPrompt, + messages: toCoreMessages(stepMessages, isAnthropic), + tools, + }; + + if (isAnthropic && effort !== "none") { + const budgetTokens = effort === "max" ? 16000 : effort === "high" ? 10000 : effort === "medium" ? 5000 : effort === "low" ? 2000 : 0; + streamOptions.providerOptions = { anthropic: { thinking: { type: "enabled" as const, budgetTokens } } }; + streamOptions.maxTokens = budgetTokens + 8000; + } else if (!isAnthropic && effort !== "none") { + streamOptions.providerOptions = { openaiCompatible: { reasoningEffort: effort } }; + } + + const result = streamText(streamOptions); let stepText = ""; const stepToolCalls: ToolCall[] = []; @@ -235,9 +266,11 @@ export class Agent { } else if (event.type === "reasoning") { yield { type: "reasoning-delta", delta: event.textDelta }; } else if (event.type === "tool-call") { + const rawName = event.toolName; + const toolName = isAnthropic ? unprefixToolName(rawName) : rawName; const toolCall: ToolCall = { id: event.toolCallId, - name: event.toolName, + name: toolName, arguments: event.args as Record<string, unknown>, }; stepToolCalls.push(toolCall); diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index d3eea98..57d0b7b 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -126,11 +126,33 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi errors.push({ path, message: "must be an object" }); return null; } - for (const field of ["id", "provider", "env", "base_url"] as const) { - if (typeof raw[field] !== "string") { - errors.push({ path: `${path}.${field}`, message: "must be a string" }); - return null; - } + if (typeof raw["id"] !== "string") { + errors.push({ path: `${path}.id`, message: "must be a string" }); + return null; + } + if (typeof raw["provider"] !== "string") { + errors.push({ path: `${path}.provider`, message: "must be a string" }); + return null; + } + if (typeof raw["base_url"] !== "string") { + errors.push({ path: `${path}.base_url`, message: "must be a string" }); + return null; + } + + // "anthropic" provider uses credentials_file instead of env + if (raw["provider"] === "anthropic") { + return { + id: raw["id"] as string, + provider: raw["provider"] as string, + base_url: raw["base_url"] as string, + ...(typeof raw["credentials_file"] === "string" ? { credentials_file: raw["credentials_file"] } as Pick<KeyDefinition, "credentials_file"> : {}), + }; + } + + // Other providers require env + if (typeof raw["env"] !== "string") { + errors.push({ path: `${path}.env`, message: "must be a string" }); + return null; } return { id: raw["id"] as string, diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts new file mode 100644 index 0000000..87568e6 --- /dev/null +++ b/packages/core/src/credentials/claude.ts @@ -0,0 +1,467 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from "node:fs"; +import { dirname, join, basename } from "node:path"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; + +export interface ClaudeCredentials { + accessToken: string; + refreshToken: string; + expiresAt: number; + subscriptionType?: string; +} + +export interface ClaudeAccount { + id: string; + label: string; + source: string; + credentials: ClaudeCredentials; +} + +const OAUTH_TOKEN_URL = "https://claude.ai/v1/oauth/token"; +const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const CREDENTIAL_CACHE_TTL_MS = 30_000; + +const CREDENTIALS_DIR = join(homedir(), ".claude"); +const PRIMARY_CREDENTIALS_FILE = join(CREDENTIALS_DIR, ".credentials.json"); + +const accountCacheMap = new Map<string, { creds: ClaudeCredentials; cachedAt: 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 as Record<string, unknown>).mcpOAuth && !(creds as Record<string, unknown>).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, + }; +} + +function readCredentialsFile(filePath: string): ClaudeCredentials | null { + try { + if (!existsSync(filePath)) return null; + const raw = readFileSync(filePath, "utf-8").trim(); + if (!raw) return null; + return parseCredentialsFile(raw); + } catch { + return null; + } +} + +function writeCredentialsFile(filePath: string, creds: ClaudeCredentials): void { + let existing: Record<string, unknown> = {}; + try { + if (existsSync(filePath)) { + const raw = readFileSync(filePath, "utf-8").trim(); + if (raw) { + existing = JSON.parse(raw); + } + } + } catch { + existing = {}; + } + + const hasWrapper = "claudeAiOauth" in existing; + const target = hasWrapper ? (existing.claudeAiOauth as Record<string, unknown>) : existing; + target.accessToken = creds.accessToken; + target.refreshToken = creds.refreshToken; + target.expiresAt = creds.expiresAt; + if (creds.subscriptionType) { + target.subscriptionType = creds.subscriptionType; + } + + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: "utf-8", mode: 0o600 }); + if (process.platform !== "win32") { + chmodSync(filePath, 0o600); + } +} + +async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials | null> { + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: OAUTH_CLIENT_ID, + refresh_token: refreshToken, + }); + + try { + const response = await fetch(OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + if (!response.ok) { + return null; + } + + const data = await response.json() as Record<string, unknown>; + if (!data.access_token || typeof data.access_token !== "string") { + return null; + } + + return { + accessToken: data.access_token as string, + refreshToken: (data.refresh_token as string) ?? refreshToken, + expiresAt: Date.now() + ((data.expires_in as number) ?? 36_000) * 1000, + subscriptionType: typeof data.subscriptionType === "string" ? data.subscriptionType : undefined, + }; + } catch { + return null; + } +} + +function buildAccountLabels(accounts: ClaudeAccount[]): void { + const counts = new Map<string, number>(); + for (const acct of accounts) { + const base = acct.credentials.subscriptionType + ? `Claude ${acct.credentials.subscriptionType.charAt(0).toUpperCase() + acct.credentials.subscriptionType.slice(1)}` + : "Claude"; + const count = (counts.get(base) ?? 0) + 1; + counts.set(base, count); + acct.label = count > 1 ? `${base} ${count}` : base; + } +} + +export function discoverClaudeAccounts(): ClaudeAccount[] { + const accounts: ClaudeAccount[] = []; + + if (!existsSync(CREDENTIALS_DIR)) { + return accounts; + } + + const primaryCreds = readCredentialsFile(PRIMARY_CREDENTIALS_FILE); + if (primaryCreds) { + accounts.push({ + id: "claude-default", + label: "", + source: PRIMARY_CREDENTIALS_FILE, + credentials: primaryCreds, + }); + } + + try { + const files = readdirSync(CREDENTIALS_DIR); + const credFiles = files.filter( + (f) => f.startsWith(".credentials") && f.endsWith(".json") && f !== ".credentials.json", + ); + for (const file of credFiles) { + const filePath = join(CREDENTIALS_DIR, file); + const creds = readCredentialsFile(filePath); + if (creds) { + const id = basename(file, ".json").replace(/^\.credentials/, "claude") || `claude-${file}`; + accounts.push({ + id, + label: "", + source: filePath, + credentials: creds, + }); + } + } + } catch { + // ignore + } + + buildAccountLabels(accounts); + return accounts; +} + +export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredentials | null { + const cached = accountCacheMap.get(account.id); + const now = Date.now(); + if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) { + return cached.creds; + } + + // Re-read from file to pick up external updates + const onDisk = readCredentialsFile(account.source); + if (onDisk) { + account.credentials = onDisk; + } + + if (account.credentials.expiresAt > now + 60_000) { + accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now }); + return account.credentials; + } + + // Try OAuth refresh + if (account.credentials.refreshToken) { + // Synchronous refresh not available in this context, but the async version will be used + // by getCredentialsForAccount below + return null; + } + + return null; +} + +export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Promise<ClaudeCredentials | null> { + const cached = accountCacheMap.get(account.id); + const now = Date.now(); + if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) { + return cached.creds; + } + + // Re-read from file + const onDisk = readCredentialsFile(account.source); + if (onDisk) { + account.credentials = onDisk; + } + + if (account.credentials.expiresAt > now + 60_000) { + accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now }); + return account.credentials; + } + + // Try OAuth refresh + if (account.credentials.refreshToken) { + const refreshed = await refreshViaOAuth(account.credentials.refreshToken); + if (refreshed && refreshed.expiresAt > now + 60_000) { + account.credentials = refreshed; + writeCredentialsFile(account.source, refreshed); + accountCacheMap.set(account.id, { creds: refreshed, cachedAt: now }); + return refreshed; + } + } + + return null; +} + +// ─── Billing Header Computation ──────────────────────────────── + +const BILLING_SALT = "59cf53e54c78"; +const CC_VERSION = "2.1.112"; + +function extractFirstUserMessageText(messages: Array<{ role: string; content: string }>): string { + const userMsg = messages.find((m) => m.role === "user"); + if (!userMsg) return ""; + if (typeof userMsg.content === "string") return userMsg.content; + return ""; +} + +function computeCch(messageText: string): string { + return createHash("sha256").update(messageText).digest("hex").slice(0, 5); +} + +function computeVersionSuffix(messageText: string, version: string): string { + const sampled = [4, 7, 20] + .map((i) => (i < messageText.length ? messageText[i] : "0")) + .join(""); + const input = `${BILLING_SALT}${sampled}${version}`; + return createHash("sha256").update(input).digest("hex").slice(0, 3); +} + +export function buildBillingHeaderValue(messages: Array<{ role: string; content: string }>): string { + const text = extractFirstUserMessageText(messages); + const version = process.env.ANTHROPIC_CLI_VERSION ?? CC_VERSION; + const suffix = computeVersionSuffix(text, version); + const cch = computeCch(text); + return `x-anthropic-billing-header: cc_version=${version}.${suffix}; cc_entrypoint=sdk-cli; cch=${cch};`; +} + +export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; + +// ─── Anthropic Beta Headers ─────────────────────────────────── + +const BASE_BETAS = [ + "claude-code-20250219", + "oauth-2025-04-20", + "interleaved-thinking-2025-05-14", + "prompt-caching-scope-2026-01-05", + "context-management-2025-06-27", + "advisor-tool-2026-03-01", +]; + +export function getAnthropicBetas(): string[] { + return [...BASE_BETAS]; +} + +// ─── Anthropic Request Headers ──────────────────────────────── + +export function getAnthropicHeaders(accessToken: string): Record<string, string> { + return { + authorization: `Bearer ${accessToken}`, + "anthropic-version": "2023-06-01", + "anthropic-beta": getAnthropicBetas().join(","), + "anthropic-dangerous-direct-browser-access": "true", + "x-app": "cli", + "user-agent": `claude-cli/${CC_VERSION} (external, sdk-cli)`, + }; +} + +// ─── Usage Tracking ─────────────────────────────────────────── + +export interface ClaudeUsageBucket { + utilization?: number; + resetsAt?: number; +} + +export interface ClaudeUsageReport { + fiveHour?: ClaudeUsageBucket; + sevenDay?: ClaudeUsageBucket; + sevenDayOpus?: ClaudeUsageBucket; + sevenDaySonnet?: ClaudeUsageBucket; + accountId?: string; + email?: string; + orgId?: string; +} + +// ─── Well-known Anthropic models ────────────────────────────── + +/** + * Fetch the live list of available models from Anthropic's /v1/models endpoint. + * Requires valid OAuth credentials with anthropic-beta headers. + */ +export async function fetchAnthropicModels(accessToken: string): Promise<string[]> { + const headers: Record<string, string> = { + ...getAnthropicHeaders(accessToken), + accept: "application/json", + }; + + try { + const response = await fetch("https://api.anthropic.com/v1/models", { headers }); + if (!response.ok) { + console.warn(`dispatch: Anthropic /v1/models returned ${response.status}`); + return []; + } + + const data = (await response.json()) as { data?: Array<{ id: string }>; models?: Array<{ id: string }> }; + const entries = data.data ?? data.models ?? []; + return entries.map((m) => m.id).filter(Boolean); + } catch (err) { + console.warn(`dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`); + return []; + } +} + +/** Fallback list if /v1/models is unreachable. */ +export const ANTHROPIC_MODELS_FALLBACK = [ + "claude-sonnet-4-20250514", + "claude-opus-4-20250514", + "claude-3.5-sonnet-20241022", + "claude-3.5-haiku-20241022", + "claude-3-opus-20240229", +]; + +// ─── Credential Validation ──────────────────────────────────── + +export interface ClaudeProfile { + accountId?: string; + email?: string; + subscriptionType?: string; +} + +/** + * Validate that Claude credentials are usable by hitting the OAuth profile endpoint. + * Returns the profile info if valid, or null if the token is dead. + */ +export async function validateAccountCredentials(account: ClaudeAccount): Promise<ClaudeProfile | null> { + const creds = await refreshAccountCredentialsAsync(account); + if (!creds) return null; + + const url = "https://api.anthropic.com/api/oauth/profile"; + const headers: Record<string, string> = { + ...getAnthropicHeaders(creds.accessToken), + accept: "application/json, text/plain, */*", + }; + + try { + const response = await fetch(url, { headers }); + if (!response.ok) return null; + + const data = (await response.json()) as Record<string, unknown>; + + const profile: ClaudeProfile = {}; + const uuid = typeof data.uuid === "string" ? data.uuid : undefined; + const email = typeof data.email === "string" ? data.email : undefined; + + if (uuid) profile.accountId = uuid; + if (email) profile.email = email; + + // subscriptionType comes from the credentials file, but profile may also carry it + profile.subscriptionType = account.credentials.subscriptionType; + + return profile; + } catch { + return null; + } +} + +async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport | null> { + const url = "https://api.anthropic.com/api/oauth/usage"; + const headers: Record<string, string> = { + ...getAnthropicHeaders(accessToken), + accept: "application/json, text/plain, */*", + "content-type": "application/json", + }; + + try { + const response = await fetch(url, { headers }); + if (!response.ok) return null; + + const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined; + const data = (await response.json()) as Record<string, unknown>; + + const parseBucket = (bucket: unknown): ClaudeUsageBucket | undefined => { + if (!bucket || typeof bucket !== "object" || Array.isArray(bucket)) return undefined; + const b = bucket as Record<string, unknown>; + const utilization = typeof b.utilization === "number" ? b.utilization : undefined; + const resetsAt = typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined; + if (utilization === undefined && resetsAt === undefined) return undefined; + return { utilization, resetsAt }; + }; + + const report: ClaudeUsageReport = { + fiveHour: parseBucket(data.five_hour), + sevenDay: parseBucket(data.seven_day), + sevenDayOpus: parseBucket(data.seven_day_opus), + sevenDaySonnet: parseBucket(data.seven_day_sonnet), + }; + + if (orgId) report.orgId = orgId; + + // Try to extract identity + const accountId = + typeof data.account_id === "string" ? data.account_id : + typeof data.user_id === "string" ? data.user_id : + typeof data.org_id === "string" ? data.org_id : undefined; + if (accountId) report.accountId = accountId; + + const email = typeof data.email === "string" ? data.email : undefined; + if (email) report.email = email; + + return report; + } catch { + return null; + } +} + +export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> { + const creds = await refreshAccountCredentialsAsync(account); + if (!creds) return null; + return fetchClaudeUsage(creds.accessToken); +}
\ No newline at end of file diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts new file mode 100644 index 0000000..d72ad48 --- /dev/null +++ b/packages/core/src/credentials/index.ts @@ -0,0 +1,18 @@ +export { + type ClaudeCredentials, + type ClaudeAccount, + type ClaudeUsageBucket, + type ClaudeUsageReport, + type ClaudeProfile, + ANTHROPIC_MODELS_FALLBACK, + fetchAnthropicModels, + discoverClaudeAccounts, + refreshAccountCredentials, + refreshAccountCredentialsAsync, + validateAccountCredentials, + buildBillingHeaderValue, + getAnthropicBetas, + getAnthropicHeaders, + getAccountUsage, + SYSTEM_IDENTITY, +} from "./claude.js";
\ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3009a0b..ae826dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,3 +26,6 @@ export { parseSkillFile, loadSkills, resolveSkillsForAgent, getSkillByName, crea // Models export { ModelRegistry, ModelResolver } from "./models/index.js"; + +// Credentials +export * from "./credentials/index.js"; diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts index 2a917b2..3131b1a 100644 --- a/packages/core/src/llm/provider.ts +++ b/packages/core/src/llm/provider.ts @@ -1,18 +1,8 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { createAnthropic } from "@ai-sdk/anthropic"; import { wrapLanguageModel } from "ai"; import type { LanguageModelV1Middleware, LanguageModelV1Prompt } from "ai"; -/** - * Normalize messages for interleaved reasoning providers (DeepSeek). - * Extracts { type: "reasoning" } / { type: "redacted-reasoning" } parts from - * assistant message content arrays and moves them into - * providerMetadata.openaiCompatible.reasoning_content so - * @ai-sdk/openai-compatible serializes them correctly for the API. - * - * IMPORTANT: The messages in params.prompt are already in LanguageModelV1Prompt - * format (post-conversion by the AI SDK). They use `providerMetadata`, NOT - * `providerOptions` — that conversion happens before the middleware runs. - */ function normalizeMessages(msgs: unknown[]): unknown[] { return msgs.map((msg: unknown) => { const message = msg as Record<string, unknown>; @@ -45,7 +35,35 @@ function normalizeMessages(msgs: unknown[]): unknown[] { }); } -export function createProvider(config: { apiKey: string; baseURL: string }) { +export interface ProviderConfig { + apiKey: string; + baseURL: string; + provider?: string; + claudeCredentials?: { + accessToken: string; + }; +} + +const MCP_PREFIX = "mcp_"; + +function prefixToolName(name: string): string { + return `${MCP_PREFIX}${name.charAt(0).toUpperCase()}${name.slice(1)}`; +} + +function unprefixToolName(name: string): string { + if (name.startsWith(MCP_PREFIX)) { + const rest = name.slice(MCP_PREFIX.length); + return `${rest.charAt(0).toLowerCase()}${rest.slice(1)}`; + } + return name; +} + +export function createProvider(config: ProviderConfig) { + if (config.provider === "anthropic") { + return createAnthropicProvider(config); + } + + // Default: OpenAI-compatible provider const provider = createOpenAICompatible({ name: "opencode-zen", apiKey: config.apiKey, @@ -72,3 +90,34 @@ export function createProvider(config: { apiKey: string; baseURL: string }) { }); }; } + +function createAnthropicProvider(config: ProviderConfig) { + const accessToken = config.claudeCredentials?.accessToken ?? config.apiKey; + + const customFetch = Object.assign( + async (url: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + const headers = new Headers(init?.headers); + headers.delete("x-api-key"); + headers.set("authorization", `Bearer ${accessToken}`); + return globalThis.fetch(url, { ...init, headers }); + }, + { preconnect: globalThis.fetch.preconnect?.bind(globalThis.fetch) }, + ); + + const anthropic = createAnthropic({ + apiKey: "sk-ant-oauth-placeholder", + baseURL: config.baseURL || "https://api.anthropic.com/v1", + headers: { + "anthropic-dangerous-direct-browser-access": "true", + "x-app": "cli", + "user-agent": "claude-cli/2.1.112 (external, sdk-cli)", + }, + fetch: customFetch as typeof globalThis.fetch, + }); + + return (modelId: string) => { + return anthropic(modelId); + }; +} + +export { prefixToolName, unprefixToolName };
\ No newline at end of file diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 5a761b5..6977564 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -56,6 +56,8 @@ export interface ToolDefinition { // ─── Agent Configuration ───────────────────────────────────────── +export type ReasoningEffort = "none" | "low" | "medium" | "high" | "max"; + export interface AgentConfig { model: string; apiKey: string; @@ -65,6 +67,11 @@ export interface AgentConfig { workingDirectory: string; permissionChecker?: PermissionChecker; ruleset?: Ruleset; + reasoningEffort?: ReasoningEffort; + provider?: string; + claudeCredentials?: { + accessToken: string; + }; } // ─── Config Types (dispatch.toml) ──────────────────────────────── @@ -95,8 +102,10 @@ export interface ModelDefinition { export interface KeyDefinition { id: string; provider: string; - env: string; + env?: string; base_url: string; + /** For "anthropic" provider: path to credentials file (default: ~/.claude/.credentials.json) */ + credentials_file?: string; } // ─── Model Resolution ──────────────────────────────────────────── diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index b221d61..a73dcac 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -4,32 +4,16 @@ import ChatInput from "./lib/components/ChatInput.svelte"; import ChatPanel from "./lib/components/ChatPanel.svelte"; import Header from "./lib/components/Header.svelte"; import PermissionPrompt from "./lib/components/PermissionPrompt.svelte"; -import PermissionLog from "./lib/components/PermissionLog.svelte"; -import ConfigPanel from "./lib/components/ConfigPanel.svelte"; -import SkillsBrowser from "./lib/components/SkillsBrowser.svelte"; -import TaskListPanel from "./lib/components/TaskListPanel.svelte"; -import ModelStatus from "./lib/components/ModelStatus.svelte"; +import ModelSelector from "./lib/components/ModelSelector.svelte"; +import SidebarPanel from "./lib/components/SidebarPanel.svelte"; import HotReloadIndicator from "./lib/components/HotReloadIndicator.svelte"; import { chatStore } from "./lib/chat.svelte.js"; import { wsClient } from "./lib/ws.svelte.js"; import { config } from "./lib/config.js"; +import type { KeyInfo, ModelInfo } from "./lib/types.js"; const STORAGE_KEY = "dispatch-theme"; -interface KeyInfo { - id: string; - provider: string; - status: "active" | "exhausted"; - lastError: string | null; - exhaustedAt: number | null; -} - -interface ModelInfo { - id: string; - provider: string; - tags: string[]; -} - let modelsData = $state<{ models: ModelInfo[]; keys: KeyInfo[]; tags: string[] }>({ models: [], keys: [], @@ -97,34 +81,27 @@ onMount(() => { class:w-0={!sidebarOpen} > <div - class="w-80 flex-1 min-h-0 overflow-y-auto bg-base-100 border-l border-base-300 px-2 py-2 flex flex-col gap-2 transition-transform duration-300 ease-out" + class="w-80 flex-1 min-h-0 overflow-y-auto bg-base-100 border-l border-base-300 px-2 py-2 flex flex-col gap-2 [&>*]:shrink-0 transition-transform duration-300 ease-out" style="transform: translateX({sidebarOpen ? '0' : '100%'})" > - <div class="collapse collapse-arrow bg-base-200"> - <input type="checkbox" checked /> - <div class="collapse-title text-sm font-medium">Model Status</div> - <div class="collapse-content"> - <ModelStatus - models={modelsData.models} - keys={modelsData.keys} - tags={modelsData.tags} - /> - </div> - </div> - - <div class="collapse collapse-arrow bg-base-200"> - <input type="checkbox" checked /> - <div class="collapse-title text-sm font-medium">Tasks</div> - <div class="collapse-content"> - <TaskListPanel tasks={chatStore.tasks} /> - </div> - </div> - - <ConfigPanel apiBase={config.apiBase} /> - - <SkillsBrowser apiBase={config.apiBase} /> - - <PermissionLog entries={chatStore.permissionLog} /> + <ModelSelector + keys={modelsData.keys} + activeKeyId={chatStore.activeKeyId} + activeModelId={chatStore.activeModelId} + reasoningEffort={chatStore.reasoningEffort} + onKeyChange={(keyId) => chatStore.setKey(keyId)} + onModelChange={(keyId, modelId) => chatStore.changeModel(keyId, modelId)} + onReasoningChange={(effort) => { chatStore.reasoningEffort = effort; }} + /> + + <SidebarPanel + models={modelsData.models} + keys={modelsData.keys} + tags={modelsData.tags} + tasks={chatStore.tasks} + permissionLog={chatStore.permissionLog} + apiBase={config.apiBase} + /> </div> </div> </div> diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts index 9559f20..e211203 100644 --- a/packages/frontend/src/lib/chat.svelte.ts +++ b/packages/frontend/src/lib/chat.svelte.ts @@ -16,13 +16,23 @@ function makeDebugInfo(overrides: Partial<DebugInfo> = {}): DebugInfo { }; } -function formatConversation(msgs: ChatMessage[]): string { +function formatConversation(msgs: ChatMessage[], activeModelId: string | null): string { const lines: string[] = []; lines.push("=== Dispatch Conversation ==="); lines.push(`Exported: ${new Date().toISOString()}`); + lines.push(`Active Model: ${activeModelId ?? "default"}`); lines.push(""); for (const msg of msgs) { + if (msg.role === "system") { + lines.push(`--- System ---`); + for (const seg of msg.content) { + if (seg.type === "text") lines.push(seg.text); + } + lines.push(""); + continue; + } + const role = msg.role === "user" ? "User" : "Assistant"; lines.push(`--- ${role} ---`); @@ -67,6 +77,9 @@ function formatConversation(msgs: ChatMessage[]): string { function createChatStore() { let messages: ChatMessage[] = $state([]); let agentStatus: "idle" | "running" | "error" = $state("idle"); + let activeKeyId: string | null = $state(null); + let activeModelId: string | null = $state(null); + let reasoningEffort: string = $state("max"); let isConnected = $state(false); let currentAssistantId: string | null = null; let pendingPermissions: PermissionPrompt[] = $state([]); @@ -147,16 +160,15 @@ function createChatStore() { ensureCurrentAssistantMessage(); messages = messages.map((m) => { if (m.id === currentAssistantId) { - const segments: ContentSegment[] = [ - ...m.content, - { - type: "tool-call", - id: event.toolCall.id, - name: event.toolCall.name, - arguments: event.toolCall.arguments, - isExpanded: false, - }, - ]; + const segments: ContentSegment[] = [ + ...m.content, + { + type: "tool-call", + id: event.toolCall.id, + name: event.toolCall.name, + arguments: event.toolCall.arguments, + }, + ]; return { ...m, content: segments }; } return m; @@ -266,7 +278,11 @@ function createChatStore() { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: text }), + body: JSON.stringify({ + message: text, + ...(activeKeyId && activeModelId ? { keyId: activeKeyId, modelId: activeModelId } : {}), + reasoningEffort, + }), }); if (!res.ok) { const body = await res.text(); @@ -301,7 +317,35 @@ function createChatStore() { } function copyConversation(): string { - return formatConversation(messages); + return formatConversation(messages, activeModelId); + } + + function changeModel(keyId: string, modelId: string) { + const previousModel = activeModelId; + activeKeyId = keyId; + activeModelId = modelId; + + if (previousModel && previousModel !== modelId) { + const systemMsg: ChatMessage = { + id: generateId(), + role: "system", + content: [{ type: "text", text: `Model changed from ${previousModel} to ${modelId}` }], + }; + messages = [...messages, systemMsg]; + } else if (!previousModel) { + const systemMsg: ChatMessage = { + id: generateId(), + role: "system", + content: [{ type: "text", text: `Model set to ${modelId}` }], + }; + messages = [...messages, systemMsg]; + } + } + + function setKey(keyId: string) { + activeKeyId = keyId; + // Clear model when key changes since available models depend on the key + activeModelId = null; } function replyPermission(id: string, reply: "once" | "always" | "reject") { @@ -358,6 +402,12 @@ function createChatStore() { replyPermission, copyConversation, clear, + changeModel, + setKey, + get activeKeyId() { return activeKeyId; }, + get activeModelId() { return activeModelId; }, + get reasoningEffort() { return reasoningEffort; }, + set reasoningEffort(value: string) { reasoningEffort = value; }, }; } diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte index 387dbdc..c8b3f6e 100644 --- a/packages/frontend/src/lib/components/ChatMessage.svelte +++ b/packages/frontend/src/lib/components/ChatMessage.svelte @@ -6,8 +6,20 @@ import ToolCallDisplay from "./ToolCallDisplay.svelte"; const { message }: { message: ChatMessage } = $props(); const isUser = $derived(message.role === "user"); +const isSystem = $derived(message.role === "system"); </script> +{#if isSystem} + <div class="flex justify-center my-2"> + <div class="badge badge-ghost gap-1 text-xs opacity-60"> + {#each message.content as segment} + {#if segment.type === "text"} + {segment.text} + {/if} + {/each} + </div> + </div> +{:else} <div class="chat chat-start mb-2"> <div class="chat-bubble max-w-[80%] break-words {isUser ? 'chat-bubble-primary' : 'bg-transparent'}"> {#if message.thinking} @@ -31,3 +43,4 @@ const isUser = $derived(message.role === "user"); {/if} </div> </div> +{/if} diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte index 5d14593..721a773 100644 --- a/packages/frontend/src/lib/components/Header.svelte +++ b/packages/frontend/src/lib/components/Header.svelte @@ -30,7 +30,7 @@ async function handleCopy() { </div> <div class="navbar-end flex items-center gap-3"> <span class="text-xs text-base-content/60 hidden sm:block"> - DeepSeek V4 Flash via OpenCode Go + {chatStore.activeModelId ?? "Default Model"} </span> <button type="button" diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte index f9925c6..f73140f 100644 --- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte +++ b/packages/frontend/src/lib/components/MarkdownRenderer.svelte @@ -92,9 +92,10 @@ const promise = (async () => { try { - // Vite resolves this template literal at build time into a glob - // over all matching language modules, so each is a separate chunk. - const mod = await import(`highlight.js/lib/languages/${name}`); + // Dynamic import for languages not in the hot set above. + // @vite-ignore: the variable `name` is intentionally dynamic; + // missing modules are caught by the try/catch below. + const mod = await import(/* @vite-ignore */ `highlight.js/lib/languages/${name}`); hljs.registerLanguage(name, mod.default); return true; } catch { diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte new file mode 100644 index 0000000..b88b2e3 --- /dev/null +++ b/packages/frontend/src/lib/components/ModelSelector.svelte @@ -0,0 +1,187 @@ +<script module> + const modelCache = new Map<string, string[]>(); +</script> + +<script lang="ts"> + import type { KeyInfo } from "../types.js"; + import { config } from "../config.js"; + + // Moves an element to document.body so modals escape the sidebar's + // transform stacking context and cover the full viewport. + function portal(node: HTMLElement) { + document.body.appendChild(node); + return { + destroy() { + node.remove(); + }, + }; + } + + const { + keys = [], + activeKeyId = null, + activeModelId = null, + reasoningEffort = "max", + onKeyChange, + onModelChange, + onReasoningChange, + }: { + keys?: KeyInfo[]; + activeKeyId?: string | null; + activeModelId?: string | null; + reasoningEffort?: string; + onKeyChange: (keyId: string) => void; + onModelChange: (keyId: string, modelId: string) => void; + onReasoningChange: (effort: string) => void; + } = $props(); + + let showKeyModal = $state(false); + let showModelModal = $state(false); + let availableModels = $state<string[]>([]); + let loadingModels = $state(false); + let modelError = $state<string | null>(null); + + function selectKey(keyId: string) { + showKeyModal = false; + onKeyChange(keyId); + } + + async function openModelModal() { + if (!activeKeyId) return; + showModelModal = true; + modelError = null; + + // Check session cache + if (modelCache.has(activeKeyId)) { + availableModels = modelCache.get(activeKeyId)!; + loadingModels = false; + return; + } + + loadingModels = true; + availableModels = []; + + try { + const res = await fetch( + `${config.apiBase}/models/available?keyId=${encodeURIComponent(activeKeyId)}`, + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + modelError = data.error ?? `Failed to fetch models (HTTP ${res.status})`; + return; + } + const data = await res.json(); + availableModels = data.models ?? []; + // Cache for session + modelCache.set(activeKeyId, availableModels); + } catch (err) { + modelError = err instanceof Error ? err.message : "Failed to fetch models"; + } finally { + loadingModels = false; + } + } + + function selectModel(model: string) { + showModelModal = false; + if (activeKeyId) { + onModelChange(activeKeyId, model); + } + } +</script> + +<div class="bg-base-200 rounded-lg p-3"> + <div class="flex items-center justify-between"> + <span class="text-sm font-medium">Key</span> + <button class="btn btn-sm btn-outline" onclick={() => (showKeyModal = true)}> + {activeKeyId ?? "Select Key"} + </button> + </div> + + <div class="flex items-center justify-between mt-2"> + <span class="text-sm font-medium">Model</span> + <button class="btn btn-sm btn-outline" onclick={openModelModal} disabled={!activeKeyId}> + {activeModelId ?? "Select Model"} + </button> + </div> + + {#if activeModelId} + <div class="flex items-center justify-between mt-2"> + <span class="text-sm font-medium">Thinking</span> + <select + class="select select-bordered select-sm" + value={reasoningEffort} + onchange={(e) => onReasoningChange(e.currentTarget.value)} + > + <option value="none">Off</option> + <option value="low">Low</option> + <option value="medium">Medium</option> + <option value="high">High</option> + <option value="max">Max</option> + </select> + </div> + {/if} +</div> + +{#if showKeyModal} + <div class="modal modal-open" use:portal> + <div class="modal-box"> + <h3 class="font-bold text-xl">Select Key</h3> + <div class="mt-4 flex flex-col gap-2"> + {#each keys as key} + <button + class="btn {key.id === activeKeyId + ? 'btn-primary' + : 'btn-ghost'} justify-start text-base" + onclick={() => selectKey(key.id)} + > + <span class="font-mono">{key.id}</span> + <span class="badge ml-auto">{key.provider}</span> + <span + class="badge {key.status === 'active' + ? 'badge-success' + : 'badge-error'}">{key.status}</span + > + </button> + {/each} + </div> + <div class="modal-action"> + <button class="btn" onclick={() => (showKeyModal = false)}>Cancel</button> + </div> + </div> + <button type="button" class="modal-backdrop" onclick={() => (showKeyModal = false)} aria-label="Close modal"></button> + </div> +{/if} + +{#if showModelModal} + <div class="modal modal-open" use:portal> + <div class="modal-box"> + <h3 class="font-bold text-xl">Select Model</h3> + {#if loadingModels} + <div class="flex justify-center py-8"> + <span class="loading loading-spinner loading-lg"></span> + </div> + {:else if modelError} + <div class="alert alert-error mt-4 text-base"> + <span>{modelError}</span> + </div> + {:else} + <div class="mt-4 flex flex-col gap-1 max-h-96 overflow-y-auto"> + {#each availableModels as model} + <button + class="btn {model === activeModelId + ? 'btn-primary' + : 'btn-ghost'} justify-start font-mono text-base" + onclick={() => selectModel(model)} + > + {model} + </button> + {/each} + </div> + {/if} + <div class="modal-action"> + <button class="btn" onclick={() => (showModelModal = false)}>Cancel</button> + </div> + </div> + <button type="button" class="modal-backdrop" onclick={() => (showModelModal = false)} aria-label="Close modal"></button> + </div> +{/if} diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte new file mode 100644 index 0000000..fe92486 --- /dev/null +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -0,0 +1,53 @@ +<script lang="ts"> + import ModelStatus from "./ModelStatus.svelte"; + import TaskListPanel from "./TaskListPanel.svelte"; + import ConfigPanel from "./ConfigPanel.svelte"; + import SkillsBrowser from "./SkillsBrowser.svelte"; + import PermissionLog from "./PermissionLog.svelte"; + import type { TaskItem, LogEntry, KeyInfo, ModelInfo } from "../types.js"; + + const { + models = [], + keys = [], + tags = [], + tasks = [], + permissionLog = [], + apiBase = "", + }: { + models?: ModelInfo[]; + keys?: KeyInfo[]; + tags?: string[]; + tasks?: TaskItem[]; + permissionLog?: LogEntry[]; + apiBase?: string; + } = $props(); + + let selected = $state("Tasks"); + + const options = ["Model Status", "Tasks", "Config", "Skills", "Permission Log"]; +</script> + +<div class="bg-base-200 rounded-lg p-3"> + <select + class="select select-bordered select-sm w-full" + bind:value={selected} + > + {#each options as option} + <option value={option}>{option}</option> + {/each} + </select> + + <div class="mt-2"> + {#if selected === "Model Status"} + <ModelStatus {models} {keys} {tags} /> + {:else if selected === "Tasks"} + <TaskListPanel {tasks} /> + {:else if selected === "Config"} + <ConfigPanel {apiBase} /> + {:else if selected === "Skills"} + <SkillsBrowser {apiBase} /> + {:else if selected === "Permission Log"} + <PermissionLog entries={permissionLog} /> + {/if} + </div> +</div> diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte index d85372c..805de6d 100644 --- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte +++ b/packages/frontend/src/lib/components/ToolCallDisplay.svelte @@ -3,7 +3,7 @@ import type { ToolCallDisplay } from "../types.js"; const { toolCall }: { toolCall: ToolCallDisplay } = $props(); -let isExpanded = $state(toolCall.isExpanded); +let isExpanded = $state(false); function toggle() { isExpanded = !isExpanded; diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index fcf6f92..9c83eac 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -4,7 +4,6 @@ export interface ToolCallDisplay { arguments: Record<string, unknown>; result?: string; isError?: boolean; - isExpanded: boolean; shellOutput?: { stdout: string; stderr: string }; } @@ -26,7 +25,7 @@ export type ContentSegment = export interface ChatMessage { id: string; - role: "user" | "assistant"; + role: "user" | "assistant" | "system"; content: ContentSegment[]; thinking?: string; isStreaming?: boolean; @@ -82,6 +81,25 @@ export interface PermissionPrompt { metadata: Record<string, unknown>; } +export interface ModelOverride { + keyId: string; + modelId: string; +} + +export interface KeyInfo { + id: string; + provider: string; + status: "active" | "exhausted"; + lastError: string | null; + exhaustedAt: number | null; +} + +export interface ModelInfo { + id: string; + provider: string; + tags: string[]; +} + export interface LogEntry { id: string; permission: string; diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index db1132c..a56ac13 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -87,16 +87,15 @@ function createTestStore(wsSend?: (data: unknown) => void) { ensureCurrentAssistantMessage(); messages = messages.map((m) => { if (m.id === currentAssistantId) { - const segments: ContentSegment[] = [ - ...m.content, - { - type: "tool-call", - id: event.toolCall.id, - name: event.toolCall.name, - arguments: event.toolCall.arguments, - isExpanded: false, - }, - ]; + const segments: ContentSegment[] = [ + ...m.content, + { + type: "tool-call", + id: event.toolCall.id, + name: event.toolCall.name, + arguments: event.toolCall.arguments, + }, + ]; return { ...m, content: segments }; } return m; |
