diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/llm | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/src/llm')
| -rw-r--r-- | packages/core/src/llm/anthropic-oauth-transform.ts | 153 | ||||
| -rw-r--r-- | packages/core/src/llm/debug-logger.ts | 448 | ||||
| -rw-r--r-- | packages/core/src/llm/provider.ts | 180 |
3 files changed, 0 insertions, 781 deletions
diff --git a/packages/core/src/llm/anthropic-oauth-transform.ts b/packages/core/src/llm/anthropic-oauth-transform.ts deleted file mode 100644 index 467a307..0000000 --- a/packages/core/src/llm/anthropic-oauth-transform.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Wire-level request restructuring for the Claude OAuth (Pro/Max) flow. - * - * Anthropic validates the `system` array on OAuth-authenticated, Claude-Code- - * billed requests. A genuine Claude Code request looks like: - * - * system: [ - * { type: "text", text: "x-anthropic-billing-header: ..." }, // system[0], NO cache_control - * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude.", - * cache_control: { type: "ephemeral" } }, // identity, separate block - * ] - * messages: [ { role: "user", content: "<the real system prompt>\n\n<user text>" }, ... ] - * - * i.e. ONLY the billing header and the verbatim identity string may live in - * `system[]`. Any third-party system prompt (Dispatch's tool/agent instructions) - * MUST be relocated into the first user message. When third-party content stays - * in `system[]` next to the identity, Anthropic bills it as premium "extra - * usage" (token burn) and refuses to apply the Claude Code prompt-cache scope — - * producing the 0% cache hit rate and ballooning cost we were seeing. - * - * Dispatch builds its system prompt as ONE concatenated block - * (`<billing>\n<identity>\n\n<systemPrompt>`); `@ai-sdk/anthropic` serializes - * that into a single `system[]` text entry. This transform runs at fetch time - * on the already-serialized JSON body and reshapes it into the structure above. - * - * Mirrors `references/opencode-claude-auth/src/transforms.ts` (`transformBody`), - * adapted to Dispatch: tool names are already PascalCase-`mcp_`-prefixed by the - * agent, so this transform leaves tools and messages (other than the relocation) - * untouched. It is defensive: any parse/shape surprise returns the body - * unchanged so a transform bug can never break a request. - */ - -const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; -const BILLING_PREFIX = "x-anthropic-billing-header"; - -type SystemBlock = { type: "text"; text: string; cache_control?: unknown } & Record< - string, - unknown ->; - -interface AnthropicRequestBody { - system?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>; - messages?: Array<{ - role?: string; - content?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>; - }>; - [key: string]: unknown; -} - -/** - * Restructure a serialized Anthropic request body string for the Claude Code - * OAuth flow. Returns the (possibly rewritten) body, or the original input - * unchanged when it isn't a JSON string we recognize. - */ -export function transformClaudeOAuthBody( - body: BodyInit | null | undefined, -): BodyInit | null | undefined { - if (typeof body !== "string") return body; - let parsed: AnthropicRequestBody; - try { - parsed = JSON.parse(body) as AnthropicRequestBody; - } catch { - return body; - } - if (!parsed || typeof parsed !== "object") return body; - try { - const changed = restructureSystem(parsed); - return changed ? JSON.stringify(parsed) : body; - } catch { - // Never let a transform bug break a real request. - return body; - } -} - -/** - * In-place restructure of `parsed.system` / `parsed.messages`. Returns true if - * anything changed (so the caller knows to re-serialize). - */ -function restructureSystem(parsed: AnthropicRequestBody): boolean { - const raw = parsed.system; - let entries: SystemBlock[]; - if (typeof raw === "string") { - entries = [{ type: "text", text: raw }]; - } else if (Array.isArray(raw)) { - entries = raw.map((e) => - typeof e === "string" - ? { type: "text", text: e } - : ({ ...e, type: "text", text: typeof e.text === "string" ? e.text : "" } as SystemBlock), - ); - } else { - return false; // no system field — nothing to do - } - - const combined = entries.map((e) => e.text).join("\n\n"); - - // Only act on Claude Code shaped requests (must carry the identity string). - if (!combined.includes(SYSTEM_IDENTITY)) return false; - - const hadCacheControl = entries.some((e) => e.cache_control != null); - - // Peel the billing-header line out (it is a single line with no newlines). - const lines = combined.split("\n"); - const billingIdx = lines.findIndex((l) => l.startsWith(BILLING_PREFIX)); - let billingLine: string | null = null; - if (billingIdx !== -1) { - billingLine = lines[billingIdx] ?? null; - lines.splice(billingIdx, 1); - } - const afterBilling = lines.join("\n").replace(/^\n+/, ""); - - // Split the identity prefix from the rest (Dispatch's real system prompt). - let rest = ""; - if (afterBilling.startsWith(SYSTEM_IDENTITY)) { - rest = afterBilling.slice(SYSTEM_IDENTITY.length).replace(/^\n+/, ""); - } else { - // Identity is present but not at the front (unexpected) — still isolate it. - rest = afterBilling.replace(SYSTEM_IDENTITY, "").replace(/^\n+/, ""); - } - - // Rebuild system[]: billing (no cache_control) then identity (cached). - const newSystem: SystemBlock[] = []; - if (billingLine) newSystem.push({ type: "text", text: billingLine }); - const identityBlock: SystemBlock = { type: "text", text: SYSTEM_IDENTITY }; - if (hadCacheControl) identityBlock.cache_control = { type: "ephemeral" }; - newSystem.push(identityBlock); - - // Relocate the third-party system prompt into the first user message. - if (rest.length > 0) { - const firstUser = Array.isArray(parsed.messages) - ? parsed.messages.find((m) => m.role === "user") - : undefined; - if (firstUser) { - if (typeof firstUser.content === "string") { - firstUser.content = `${rest}\n\n${firstUser.content}`; - } else if (Array.isArray(firstUser.content)) { - firstUser.content.unshift({ type: "text", text: rest }); - } else { - firstUser.content = rest; - } - } else { - // No user message to host it — keep it as a (cached) system block so - // the request still carries the instructions. - const restBlock: SystemBlock = { type: "text", text: rest }; - if (hadCacheControl) restBlock.cache_control = { type: "ephemeral" }; - newSystem.push(restBlock); - } - } - - parsed.system = newSystem; - return true; -} - -export const __test = { restructureSystem, SYSTEM_IDENTITY, BILLING_PREFIX }; diff --git a/packages/core/src/llm/debug-logger.ts b/packages/core/src/llm/debug-logger.ts deleted file mode 100644 index 072a7a1..0000000 --- a/packages/core/src/llm/debug-logger.ts +++ /dev/null @@ -1,448 +0,0 @@ -/** - * Debug logger for LLM API requests and responses. - * - * Enable via environment variable: DISPATCH_DEBUG_LLM=1 - * - * Logs every outgoing request body and incoming response body to timestamped - * files under `DISPATCH_DEBUG_LLM_DIR` (default: /tmp/dispatch/llm-debug/). - * - * Each request/response pair shares a sequence number for easy correlation. - * Files are named: `{seq}_{timestamp}_{direction}_{model}.json` - * - * For streaming responses (SSE), the raw chunks are captured as they arrive - * and written out as a JSON array when the stream completes. - * - * Additional logging layers: - * - Stream events: every AI SDK stream event (text-delta, tool-call, etc.) - * - Step lifecycle: step start/end, tool execution timing - * - Agent loop: step count, break conditions, tool call counts - * - * All output goes to stderr (console.error) for stream event logs, and to - * files for request/response bodies (too large for console). - */ - -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -const ENABLED = !!process.env.DISPATCH_DEBUG_LLM; -const LOG_DIR = process.env.DISPATCH_DEBUG_LLM_DIR || "/tmp/dispatch/llm-debug"; -let seq = 0; - -/** Verbosity levels: - * 1 = requests/responses only (files) - * 2 = + stream events to stderr - * 3 = + step lifecycle + agent loop details to stderr - */ -const VERBOSITY = Math.max(1, Number(process.env.DISPATCH_DEBUG_LLM_VERBOSITY) || 1); - -function ensureDir(): void { - try { - mkdirSync(LOG_DIR, { recursive: true }); - } catch { - // best effort - } -} - -function ts(): string { - return new Date().toISOString().replace(/[:.]/g, "-"); -} - -function sanitizeModel(model: string): string { - return model.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 60); -} - -function sanitizeTab(tabId?: string): string { - if (!tabId) return "notab"; - return tabId.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 40); -} - -export function isDebugEnabled(): boolean { - return ENABLED; -} - -export function debugVerbosity(): number { - return ENABLED ? VERBOSITY : 0; -} - -/** - * Allocate a fresh sequence number. Used by the fetch wrapper so the request - * and the response share the same id without needing a separate `logRequest` - * call from the agent loop (which doesn't see the actual HTTP body anyway). - */ -export function nextDebugSeq(): number { - return ++seq; -} - -/** - * Log an outgoing request to the AI model endpoint. - * Returns a request ID for correlating with the response. - */ -export function logRequest(data: { - model: string; - url?: string; - method?: string; - headers?: Record<string, string>; - body: unknown; - tabId?: string; - step?: number; - provider?: string; -}): number { - if (!ENABLED) return -1; - ensureDir(); - const id = ++seq; - const filename = `${String(id).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_REQ_${sanitizeModel(data.model)}.json`; - const payload = { - _debug: { - seq: id, - direction: "request", - timestamp: new Date().toISOString(), - tabId: data.tabId, - step: data.step, - provider: data.provider, - }, - url: data.url, - method: data.method ?? "POST", - headers: data.headers, - body: data.body, - }; - try { - writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2)); - } catch (err) { - console.error(`[dispatch-debug] Failed to write request log: ${err}`); - } - console.error( - `[dispatch-debug] REQ #${id} → ${data.model} (step=${data.step ?? "?"}, tab=${data.tabId ?? "?"})`, - ); - return id; -} - -/** - * Log the raw fetch-level request (the actual HTTP body sent to the provider). - * Called from the instrumented fetch wrapper. - */ -export function logRawFetchRequest(data: { - requestId: number; - url: string; - method: string; - headers: Record<string, string>; - body: string | null; - tabId?: string; -}): void { - if (!ENABLED) return; - ensureDir(); - const filename = `${String(data.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_RAW_REQ.json`; - const payload = { - _debug: { - seq: data.requestId, - direction: "raw-request", - timestamp: new Date().toISOString(), - tabId: data.tabId, - }, - url: data.url, - method: data.method, - headers: data.headers, - body: tryParseJson(data.body), - }; - try { - writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2)); - } catch (err) { - console.error(`[dispatch-debug] Failed to write raw request log: ${err}`); - } -} - -/** - * Log the raw fetch-level response (HTTP status, headers, body). - */ -export function logRawFetchResponse(data: { - requestId: number; - url: string; - status: number; - statusText: string; - headers: Record<string, string>; - body: string | null; - isStreaming: boolean; - tabId?: string; -}): void { - if (!ENABLED) return; - ensureDir(); - const filename = `${String(data.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_RAW_RES_${data.status}.json`; - const payload = { - _debug: { - seq: data.requestId, - direction: "raw-response", - timestamp: new Date().toISOString(), - isStreaming: data.isStreaming, - tabId: data.tabId, - }, - url: data.url, - status: data.status, - statusText: data.statusText, - headers: data.headers, - body: tryParseJson(data.body), - }; - try { - writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2)); - } catch (err) { - console.error(`[dispatch-debug] Failed to write raw response log: ${err}`); - } -} - -/** - * Accumulator for streaming response chunks. Call `addChunk()` as SSE events - * arrive, then `flush()` when the stream ends to write them all to disk. - */ -export class StreamResponseLogger { - private requestId: number; - private model: string; - private tabId?: string; - private chunks: Array<{ timestamp: string; data: string }> = []; - private startTime: number; - - constructor(requestId: number, model: string, tabId?: string) { - this.requestId = requestId; - this.model = model; - this.tabId = tabId; - this.startTime = Date.now(); - } - - addChunk(rawLine: string): void { - if (!ENABLED) return; - this.chunks.push({ - timestamp: new Date().toISOString(), - data: rawLine, - }); - } - - flush(meta?: { finishReason?: string; error?: string }): void { - if (!ENABLED) return; - ensureDir(); - const elapsed = Date.now() - this.startTime; - const filename = `${String(this.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(this.tabId)}_STREAM_RES_${sanitizeModel(this.model)}.json`; - const payload = { - _debug: { - seq: this.requestId, - direction: "stream-response", - timestamp: new Date().toISOString(), - tabId: this.tabId, - model: this.model, - elapsedMs: elapsed, - chunkCount: this.chunks.length, - ...meta, - }, - chunks: this.chunks, - }; - try { - writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2)); - } catch (err) { - console.error(`[dispatch-debug] Failed to write stream response log: ${err}`); - } - console.error( - `[dispatch-debug] STREAM #${this.requestId} complete: ${this.chunks.length} chunks in ${elapsed}ms (${this.model})`, - ); - } -} - -/** - * Log an AI SDK stream event (text-delta, tool-call, finish-step, etc.). - * Only logs at verbosity >= 2. - */ -export function logStreamEvent(data: { - requestId: number; - step: number; - eventType: string; - detail?: unknown; - tabId?: string; -}): void { - if (!ENABLED || VERBOSITY < 2) return; - const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : ""; - console.error( - `[dispatch-debug] STREAM_EVENT #${data.requestId} step=${data.step} ${data.eventType}${detail}`, - ); -} - -/** - * Log step lifecycle events (step start, tool execution, step end). - * Only logs at verbosity >= 3. - */ -export function logStepLifecycle(data: { - tabId?: string; - step: number; - event: string; - detail?: unknown; -}): void { - if (!ENABLED || VERBOSITY < 3) return; - const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : ""; - console.error( - `[dispatch-debug] STEP tab=${data.tabId ?? "?"} step=${data.step} ${data.event}${detail}`, - ); -} - -/** - * Log agent loop-level events (loop start, break conditions, etc.). - * Only logs at verbosity >= 3. - */ -export function logAgentLoop(data: { tabId?: string; event: string; detail?: unknown }): void { - if (!ENABLED || VERBOSITY < 3) return; - const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : ""; - console.error(`[dispatch-debug] AGENT tab=${data.tabId ?? "?"} ${data.event}${detail}`); -} - -/** - * Wrap a fetch function so every request/response pair is logged to disk - * under `DISPATCH_DEBUG_LLM_DIR` when `DISPATCH_DEBUG_LLM` is set. When - * disabled, returns the input fetch unchanged (zero overhead). - * - * Critical implementation note — SSE bodies: the AI SDK consumes - * `response.body` as a `ReadableStream`. Reading it from anywhere else - * (e.g. calling `.text()`) drains the stream and the SDK gets an empty - * body. We therefore `response.clone()` the response and tee its body via - * a `TransformStream` so each SSE line is forwarded to the SDK AND - * captured into a `StreamResponseLogger`. The clone returns its own - * Response object whose body the SDK reads normally. - * - * For non-streaming responses (`content-type` is not `text/event-stream`) - * we just clone and read once via `.text()` — simpler and safe because - * non-streaming bodies are bounded. - */ -export function wrapFetchWithLogging<F extends (...args: never[]) => Promise<Response> | Response>( - baseFetch: F, - opts: { tabId?: string; modelHint?: string }, -): F { - if (!ENABLED) return baseFetch; - const wrapped = async (...args: Parameters<F>) => { - const requestId = ++seq; - const [input, init] = args as unknown as [RequestInfo | URL, RequestInit | undefined]; - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - const method = - init?.method ?? - (typeof input === "object" && "method" in input ? (input as Request).method : "POST"); - - // Snapshot headers as a plain object for logging. - const headerObj: Record<string, string> = {}; - try { - const h = new Headers(init?.headers); - h.forEach((v, k) => { - // Redact bearer / api-key headers — useful in shared logs. - if (/^(authorization|x-api-key|cookie)$/i.test(k)) { - headerObj[k] = "<redacted>"; - } else { - headerObj[k] = v; - } - }); - } catch { - // best effort - } - - // Capture request body. Most providers send a JSON string here; if it's - // a stream/blob/etc. we skip body logging (rare in our codebase). - let bodyStr: string | null = null; - if (typeof init?.body === "string") { - bodyStr = init.body; - } else if (init?.body instanceof Uint8Array) { - bodyStr = new TextDecoder().decode(init.body); - } - - logRawFetchRequest({ - requestId, - url, - method, - headers: headerObj, - body: bodyStr, - tabId: opts.tabId, - }); - - const response = await (baseFetch as unknown as typeof fetch)(input, init); - - const respHeaders: Record<string, string> = {}; - response.headers.forEach((v, k) => { - respHeaders[k] = v; - }); - const contentType = response.headers.get("content-type") ?? ""; - const isStreaming = contentType.includes("text/event-stream"); - - if (!isStreaming) { - // Clone so we don't drain the SDK's copy. Bounded body — safe to read. - try { - const cloned = response.clone(); - const text = await cloned.text(); - logRawFetchResponse({ - requestId, - url, - status: response.status, - statusText: response.statusText, - headers: respHeaders, - body: text, - isStreaming: false, - tabId: opts.tabId, - }); - } catch (err) { - console.error(`[dispatch-debug] Failed to clone non-stream response: ${err}`); - } - return response; - } - - // Streaming path: write a header file with status + headers immediately - // (the body file comes later via StreamResponseLogger.flush). - logRawFetchResponse({ - requestId, - url, - status: response.status, - statusText: response.statusText, - headers: respHeaders, - body: null, - isStreaming: true, - tabId: opts.tabId, - }); - - // Tee the body through a TransformStream so each SSE chunk is captured - // without consuming the stream the SDK needs. - const streamLogger = new StreamResponseLogger( - requestId, - opts.modelHint ?? "stream", - opts.tabId, - ); - const decoder = new TextDecoder(); - const tee = new TransformStream<Uint8Array, Uint8Array>({ - transform(chunk, controller) { - try { - streamLogger.addChunk(decoder.decode(chunk, { stream: true })); - } catch { - // never let logging break the stream - } - controller.enqueue(chunk); - }, - flush() { - try { - streamLogger.flush(); - } catch { - // best effort - } - }, - }); - - // `response.body` is `ReadableStream<Uint8Array> | null`. If null (no - // body), there's nothing to tee — return as-is. - if (!response.body) return response; - const teed = response.body.pipeThrough(tee); - return new Response(teed, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - }; - return wrapped as unknown as F; -} - -function tryParseJson(s: string | null): unknown { - if (s === null) return null; - try { - return JSON.parse(s); - } catch { - return s; - } -} diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts deleted file mode 100644 index ca734f9..0000000 --- a/packages/core/src/llm/provider.ts +++ /dev/null @@ -1,180 +0,0 @@ -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/[email protected]` 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<FetchFunction>[0], - init?: Parameters<FetchFunction>[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 }; |
