diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 20:40:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 20:40:35 +0900 |
| commit | 8151447758e6826a578363758a755c6cebd1c05f (patch) | |
| tree | 6afa780c28ca6e4622c1ab30238665caaad4371e /packages/core/src | |
| parent | f05099d450748cc7508f8cbde4e6539db2105f6d (diff) | |
| download | dispatch-8151447758e6826a578363758a755c6cebd1c05f.tar.gz dispatch-8151447758e6826a578363758a755c6cebd1c05f.zip | |
feat: claude max oauth support with multi-account switching, reasoning effort, and dynamic model listing
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/agent/agent.ts | 61 | ||||
| -rw-r--r-- | packages/core/src/config/schema.ts | 32 | ||||
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 467 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 18 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/llm/provider.ts | 73 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 11 |
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 ──────────────────────────────────────────── |
