summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/llm
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/llm
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-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.ts153
-rw-r--r--packages/core/src/llm/debug-logger.ts448
-rw-r--r--packages/core/src/llm/provider.ts180
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 };