summaryrefslogtreecommitdiffhomepage
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
parentf05099d450748cc7508f8cbde4e6539db2105f6d (diff)
downloaddispatch-8151447758e6826a578363758a755c6cebd1c05f.tar.gz
dispatch-8151447758e6826a578363758a755c6cebd1c05f.zip
feat: claude max oauth support with multi-account switching, reasoning effort, and dynamic model listing
-rw-r--r--.gitignore1
-rwxr-xr-xbin/down2
-rwxr-xr-xbin/up2
-rw-r--r--bun.lock3
-rw-r--r--dispatch.toml22
-rw-r--r--docker-compose.yml1
-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
-rw-r--r--packages/core/package.json1
-rw-r--r--packages/core/src/agent/agent.ts61
-rw-r--r--packages/core/src/config/schema.ts32
-rw-r--r--packages/core/src/credentials/claude.ts467
-rw-r--r--packages/core/src/credentials/index.ts18
-rw-r--r--packages/core/src/index.ts3
-rw-r--r--packages/core/src/llm/provider.ts73
-rw-r--r--packages/core/src/types/index.ts11
-rw-r--r--packages/frontend/src/App.svelte67
-rw-r--r--packages/frontend/src/lib/chat.svelte.ts76
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte13
-rw-r--r--packages/frontend/src/lib/components/Header.svelte2
-rw-r--r--packages/frontend/src/lib/components/MarkdownRenderer.svelte7
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte187
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte53
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte2
-rw-r--r--packages/frontend/src/lib/types.ts22
-rw-r--r--packages/frontend/tests/chat-store.test.ts19
27 files changed, 1362 insertions, 128 deletions
diff --git a/.gitignore b/.gitignore
index 3d7cb2d..351f9e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ build/
*.sqlite
.DS_Store
.skills/
+references/
diff --git a/bin/down b/bin/down
index 12a6000..9188ca2 100755
--- a/bin/down
+++ b/bin/down
@@ -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 "$@"
diff --git a/bin/up b/bin/up
index 25eb484..03df1b7 100755
--- a/bin/up
+++ b/bin/up
@@ -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 "$@"
diff --git a/bun.lock b/bun.lock
index 3e93a00..ce7c398 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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;