summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 20:40:35 +0900
committerAdam Malczewski <[email protected]>2026-05-20 20:40:35 +0900
commit8151447758e6826a578363758a755c6cebd1c05f (patch)
tree6afa780c28ca6e4622c1ab30238665caaad4371e /packages/core/src
parentf05099d450748cc7508f8cbde4e6539db2105f6d (diff)
downloaddispatch-8151447758e6826a578363758a755c6cebd1c05f.tar.gz
dispatch-8151447758e6826a578363758a755c6cebd1c05f.zip
feat: claude max oauth support with multi-account switching, reasoning effort, and dynamic model listing
Diffstat (limited to 'packages/core/src')
-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
7 files changed, 633 insertions, 32 deletions
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 ────────────────────────────────────────────