import { randomUUID } from "node:crypto"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import type { LanguageModelV3 } from "@ai-sdk/provider"; import type { FetchFunction } from "@ai-sdk/provider-utils"; import { getAnthropicBetas } from "../credentials/anthropic-betas.js"; import { transformClaudeOAuthBody } from "./anthropic-oauth-transform.js"; import { wrapFetchWithLogging } from "./debug-logger.js"; export interface ProviderConfig { apiKey: string; baseURL: string; provider?: string; claudeCredentials?: { accessToken: string; }; /** Optional tab id for labelling debug logs. No effect when * `DISPATCH_DEBUG_LLM` is unset. */ tabId?: 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; } // Explicit factory return type so the inferred type doesn't leak references // into transitive `@ai-sdk/provider` paths (which would trip TS2742). // `@ai-sdk/anthropic` v3.x and `@ai-sdk/openai-compatible` v2.x both return // `LanguageModelV3`-spec models; `wrapLanguageModel` likewise. export type ModelFactory = (modelId: string) => LanguageModelV3; export function createProvider(config: ProviderConfig): ModelFactory { if (config.provider === "anthropic") { return createClaudeOAuthProvider(config); } if (config.provider === "opencode-anthropic") { return createApiKeyAnthropicProvider(config); } // Default: OpenAI-compatible provider (OpenCode Zen — DeepSeek, GLM, // Kimi, MiniMax, etc.). // // `@ai-sdk/openai-compatible@2.x` handles reasoning round-tripping // natively: it reads `{ type: "reasoning", text }` parts from each // assistant message's content and emits them as `reasoning_content` // on the wire (see node_modules/@ai-sdk/openai-compatible/dist/index.mjs // lines 215-216 and 245). Our `toModelMessages` in agent.ts already // emits reasoning parts from `ThinkingChunk`s, so no middleware is // needed. // // (The v4-era `normalizeMessages` middleware that lived here was // actively breaking DeepSeek: it stripped reasoning parts from // content AND wrote them under `providerMetadata` — wrong key in v3 // prompts, which use `providerOptions`. The result was that // reasoning_content never reached the wire and DeepSeek rejected the // follow-up turn with "must be passed back".) // // Debug logging: when DISPATCH_DEBUG_LLM is set, wrap the base fetch // so every wire request/response (including SSE chunks) is captured. // When disabled, `wrapFetchWithLogging` returns the input unchanged // (zero overhead). const loggingFetch = wrapFetchWithLogging(globalThis.fetch, { tabId: config.tabId, modelHint: "opencode-zen", }) as unknown as FetchFunction; const provider = createOpenAICompatible({ name: "opencode-zen", apiKey: config.apiKey, baseURL: config.baseURL, fetch: loggingFetch, }); return (modelId: string) => provider(modelId); } /** * Claude OAuth provider. Used by Dispatch's `anthropic` provider keys * (claude-pro, claude-max). Uses `authToken` to send `Authorization: Bearer` * (natively supported by `@ai-sdk/anthropic` v3.x), and mimics Claude Code CLI * request headers so the request bills against the user's Claude subscription. * * The `anthropic-beta` header is REQUIRED here. `@ai-sdk/anthropic` only emits * an `anthropic-beta` header for betas it auto-derives from tool definitions * (computer-use, structured-outputs, etc.) — it does NOT add the prompt-caching * or oauth betas on its own. Without `prompt-caching-scope-2026-01-05` the API * silently ignores every `cache_control` breakpoint we attach to messages, * giving a 0% cache hit rate and a massive token burn (see notes/claude-report.md). * The SDK folds any `anthropic-beta` it finds on the provider's config headers * back into its own beta set (via `getBetasFromHeaders`), so the values here * are merged — not overwritten — with any tool-derived betas. */ function createClaudeOAuthProvider(config: ProviderConfig): ModelFactory { // Stable per-provider session id — mirrors the Claude Code CLI, which sends // the same `X-Claude-Code-Session-Id` across a session's requests. const sessionId = randomUUID(); // Wrap the base fetch FIRST so the logging wrapper sees the genuine // outgoing HTTP body — i.e. AFTER the OAuth body transform and AFTER the // Claude-Code session headers have been stamped on. Order matters: if we // wrapped the inner `baseFetch` instead, the logs would show the pre- // transform body and miss the session headers, defeating the point of // capturing the wire for cache/billing debugging. const baseFetch = wrapFetchWithLogging(globalThis.fetch, { tabId: config.tabId, modelHint: "claude-oauth", }); // Custom fetch that (1) restructures the request body into the genuine // Claude Code system layout — required for Anthropic to bill correctly and // apply the prompt-cache scope (see anthropic-oauth-transform.ts) — and // (2) stamps the Claude Code session/request id headers the real CLI sends. // Cast through `unknown`: `FetchFunction` is `typeof globalThis.fetch`, whose // (Bun) type carries a `preconnect` member a plain wrapper can't satisfy. const oauthFetch = (async ( input: Parameters[0], init?: Parameters[1], ) => { const nextInit: RequestInit = { ...init }; if (init?.body != null) { nextInit.body = transformClaudeOAuthBody(init.body) ?? init.body; } const headers = new Headers(init?.headers); headers.set("X-Claude-Code-Session-Id", sessionId); if (!headers.has("x-client-request-id")) { headers.set("x-client-request-id", randomUUID()); } nextInit.headers = headers; return baseFetch(input, nextInit); }) as unknown as FetchFunction; const anthropic = createAnthropic({ baseURL: config.baseURL || "https://api.anthropic.com/v1", authToken: config.claudeCredentials?.accessToken ?? config.apiKey, fetch: oauthFetch, headers: { "anthropic-beta": getAnthropicBetas().join(","), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", "user-agent": "claude-cli/2.1.112 (external, sdk-cli)", }, }); return (modelId: string) => anthropic(modelId); } /** * Plain-API-key Anthropic-format provider. Used to hit gateways that speak * Anthropic's `/messages` protocol with a standard `x-api-key` header — most * importantly OpenCode Go's MiniMax and Qwen routes. Unlike the Claude OAuth * variant, no `claudeCredentials` are present, no Claude Code mimicry headers * are sent, and the API key is passed verbatim through the SDK's default * authentication path. */ function createApiKeyAnthropicProvider(config: ProviderConfig): ModelFactory { const loggingFetch = wrapFetchWithLogging(globalThis.fetch, { tabId: config.tabId, modelHint: "opencode-anthropic", }) as unknown as FetchFunction; const anthropic = createAnthropic({ apiKey: config.apiKey, baseURL: config.baseURL || "https://opencode.ai/zen/go/v1", fetch: loggingFetch, }); return (modelId: string) => anthropic(modelId); } export { prefixToolName, unprefixToolName };