anthropic-messages.ts
42 KB1346 lines
anthropic-messages.ts
1import Anthropic from "@anthropic-ai/sdk";2import type {3 CacheControlEphemeral,4 ContentBlockParam,5 MessageCreateParamsStreaming,6 MessageParam,7 RawMessageStreamEvent,8 RefusalStopDetails,9} from "@anthropic-ai/sdk/resources/messages.js";10import { calculateCost } from "../models.ts";11import type {12 AnthropicMessagesCompat,13 Api,14 AssistantMessage,15 CacheRetention,16 Context,17 ImageContent,18 Message,19 Model,20 ProviderEnv,21 ProviderHeaders,22 SimpleStreamOptions,23 StopReason,24 StreamFunction,25 StreamOptions,26 TextContent,27 ThinkingContent,28 Tool,29 ToolCall,30 ToolResultMessage,31} from "../types.ts";32import { splitDeferredTools } from "../utils/deferred-tools.ts";33import { AssistantMessageEventStream } from "../utils/event-stream.ts";34import { headersToRecord } from "../utils/headers.ts";35import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";36import { getProviderEnvValue } from "../utils/provider-env.ts";37import { retryProviderRequest } from "../utils/provider-retry.ts";38import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";3940import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";41import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";42import { adjustMaxTokensForThinking, buildBaseOptions, clampMaxTokensToContext } from "./simple-options.ts";43import { transformMessages } from "./transform-messages.ts";4445/**46 * Resolve cache retention preference.47 * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.48 */49function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {50 if (cacheRetention) {51 return cacheRetention;52 }53 if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {54 return "long";55 }56 return "short";57}5859function getCacheControl(60 model: Model<"anthropic-messages">,61 cacheRetention?: CacheRetention,62 env?: ProviderEnv,63): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } {64 const retention = resolveCacheRetention(cacheRetention, env);65 if (retention === "none") {66 return { retention };67 }68 const ttl = retention === "long" && getAnthropicCompat(model).supportsLongCacheRetention ? "1h" : undefined;69 return {70 retention,71 cacheControl: { type: "ephemeral", ...(ttl && { ttl }) },72 };73}7475// Stealth mode: Mimic Claude Code's tool naming exactly76const claudeCodeVersion = "2.1.75";7778// Claude Code 2.x tool names (canonical casing)79// Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md80// To update: https://github.com/badlogic/cchistory81const claudeCodeTools = [82 "Read",83 "Write",84 "Edit",85 "Bash",86 "Grep",87 "Glob",88 "AskUserQuestion",89 "EnterPlanMode",90 "ExitPlanMode",91 "KillShell",92 "NotebookEdit",93 "Skill",94 "Task",95 "TaskOutput",96 "TodoWrite",97 "WebFetch",98 "WebSearch",99];100101const ccToolLookup = new Map(claudeCodeTools.map((t) => [t.toLowerCase(), t]));102103// Convert tool name to CC canonical casing if it matches (case-insensitive)104const toClaudeCodeName = (name: string) => ccToolLookup.get(name.toLowerCase()) ?? name;105const fromClaudeCodeName = (name: string, tools?: Tool[]) => {106 if (tools && tools.length > 0) {107 const lowerName = name.toLowerCase();108 const matchedTool = tools.find((tool) => tool.name.toLowerCase() === lowerName);109 if (matchedTool) return matchedTool.name;110 }111 return name;112};113114/**115 * Convert content blocks to Anthropic API format116 */117function convertContentBlocks(content: (TextContent | ImageContent)[]):118 | string119 | Array<120 | { type: "text"; text: string }121 | {122 type: "image";123 source: {124 type: "base64";125 media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp";126 data: string;127 };128 }129 > {130 // If only text blocks, return as concatenated string for simplicity131 const hasImages = content.some((c) => c.type === "image");132 if (!hasImages) {133 return sanitizeSurrogates(content.map((c) => (c as TextContent).text).join("\n"));134 }135136 // If we have images, convert to content block array137 const blocks = content.map((block) => {138 if (block.type === "text") {139 return {140 type: "text" as const,141 text: sanitizeSurrogates(block.text),142 };143 }144 return {145 type: "image" as const,146 source: {147 type: "base64" as const,148 media_type: block.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp",149 data: block.data,150 },151 };152 });153154 // If only images (no text), add placeholder text block155 const hasText = blocks.some((b) => b.type === "text");156 if (!hasText) {157 blocks.unshift({158 type: "text" as const,159 text: "(see attached image)",160 });161 }162163 return blocks;164}165166export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max";167168export type AnthropicThinkingDisplay = "summarized" | "omitted";169170const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14";171const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";172173function getAnthropicCompat(174 model: Model<"anthropic-messages">,175): Required<Omit<AnthropicMessagesCompat, "forceAdaptiveThinking">> {176 return {177 supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true,178 supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,179 sendSessionAffinityHeaders: model.compat?.sendSessionAffinityHeaders ?? false,180 supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? true,181 supportsTemperature: model.compat?.supportsTemperature ?? true,182 allowEmptySignature: model.compat?.allowEmptySignature ?? false,183 supportsStrictTools: model.compat?.supportsStrictTools ?? false,184 supportsToolReferences: model.compat?.supportsToolReferences ?? defaultSupportsToolReferences(model),185 };186}187188/**189 * Default for `supportsToolReferences`: first-party Anthropic models except190 * Haiku (rejects client-side tool_reference blocks) and models that predate191 * tool search (Claude 3.x, Opus/Sonnet 4.0, Opus 4.1).192 */193function defaultSupportsToolReferences(model: Model<"anthropic-messages">): boolean {194 if (model.provider !== "anthropic" || model.id.includes("haiku")) return false;195 const version = model.id.match(/^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/);196 if (!version) return false;197 const major = Number(version[1]);198 const minor = version[2] && version[2].length < 8 ? Number(version[2]) : 0;199 return major > 4 || (major === 4 && minor >= 5);200}201202export interface AnthropicOptions extends StreamOptions {203 /**204 * Enable extended thinking.205 * For adaptive thinking models: the model decides when/how much to think.206 * For older models: uses budget-based thinking with thinkingBudgetTokens.207 * Default: undefined (thinking is omitted unless `streamSimple()` maps208 * a simple reasoning level to this option, or callers set it explicitly).209 */210 thinkingEnabled?: boolean;211 /**212 * Token budget for extended thinking (older models only).213 * Ignored for adaptive thinking models.214 * Default: 1024 when `thinkingEnabled` is true and no budget is provided.215 */216 thinkingBudgetTokens?: number;217 /**218 * Effort level for adaptive thinking models.219 * Controls how much thinking Claude allocates:220 * - "max": Always thinks with no constraints (Opus 4.6 only)221 * - "xhigh": Highest reasoning level (Opus 4.7+, Fable 5)222 * - "high": Always thinks, deep reasoning223 * - "medium": Moderate thinking, may skip for simple queries224 * - "low": Minimal thinking, skips for simple tasks225 * Ignored for older models.226 * Default: omitted unless `streamSimple()` maps a simple reasoning227 * level to this option.228 */229 effort?: AnthropicEffort;230 /**231 * Controls how thinking content is returned in API responses.232 * - "summarized": Thinking blocks contain summarized thinking text.233 * - "omitted": Thinking blocks return an empty thinking field; the encrypted234 * signature still travels back for multi-turn continuity. Use for faster235 * time-to-first-text-token when your UI does not surface thinking.236 *237 * Note: Anthropic's API default for Claude Opus 4.7 and Claude Mythos Preview238 * is "omitted". We default to "summarized" here to keep behavior consistent239 * with older Claude 4 models. Set this explicitly to "omitted" to opt in.240 * Default: "summarized" when thinking is enabled.241 */242 thinkingDisplay?: AnthropicThinkingDisplay;243 /**244 * Whether to request the interleaved thinking beta header for non-adaptive245 * thinking models. Adaptive thinking models have interleaved thinking built in,246 * so the header is skipped for them regardless of this setting.247 * Default: true.248 */249 interleavedThinking?: boolean;250 /**251 * Anthropic tool choice behavior. String values map to Anthropic's built-in252 * choices; `{ type: "tool", name }` forces a specific tool.253 * Default: omitted (Anthropic default behavior, currently equivalent to auto).254 */255 toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };256 /**257 * Pre-built Anthropic client instance. When provided, skips internal client258 * construction entirely. Use this to inject alternative SDK clients such as259 * `AnthropicVertex` that shares the same messaging API.260 */261 client?: Anthropic;262}263264function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): ProviderHeaders {265 const merged: ProviderHeaders = {};266 for (const headers of headerSources) {267 if (headers) {268 Object.assign(merged, headers);269 }270 }271 return merged;272}273274function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean {275 if (!headers) return false;276 const expected = name.toLowerCase();277 for (const [key, value] of Object.entries(headers)) {278 if (key.toLowerCase() === expected && value !== null && value.trim().length > 0) return true;279 }280 return false;281}282283function assertRequestAuth(provider: string, apiKey: string | undefined, headers: ProviderHeaders | undefined): void {284 if (apiKey) return;285 if (286 hasHeader(headers, "authorization") ||287 hasHeader(headers, "x-api-key") ||288 hasHeader(headers, "cf-aig-authorization")289 ) {290 return;291 }292 throw new Error(`No API key for provider: ${provider}`);293}294295interface ServerSentEvent {296 event: string | null;297 data: string;298 raw: string[];299}300301interface SseDecoderState {302 event: string | null;303 data: string[];304 raw: string[];305}306307const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet<string> = new Set([308 "message_start",309 "message_delta",310 "message_stop",311 "content_block_start",312 "content_block_delta",313 "content_block_stop",314]);315316function flushSseEvent(state: SseDecoderState): ServerSentEvent | null {317 if (!state.event && state.data.length === 0) {318 return null;319 }320321 const event: ServerSentEvent = {322 event: state.event,323 data: state.data.join("\n"),324 raw: [...state.raw],325 };326 state.event = null;327 state.data = [];328 state.raw = [];329 return event;330}331332function decodeSseLine(line: string, state: SseDecoderState): ServerSentEvent | null {333 if (line === "") {334 return flushSseEvent(state);335 }336337 state.raw.push(line);338 if (line.startsWith(":")) {339 return null;340 }341342 const delimiterIndex = line.indexOf(":");343 const fieldName = delimiterIndex === -1 ? line : line.slice(0, delimiterIndex);344 let value = delimiterIndex === -1 ? "" : line.slice(delimiterIndex + 1);345 if (value.startsWith(" ")) {346 value = value.slice(1);347 }348349 if (fieldName === "event") {350 state.event = value;351 } else if (fieldName === "data") {352 state.data.push(value);353 }354355 return null;356}357358function nextLineBreakIndex(text: string): number {359 const carriageReturnIndex = text.indexOf("\r");360 const newlineIndex = text.indexOf("\n");361 if (carriageReturnIndex === -1) {362 return newlineIndex;363 }364 if (newlineIndex === -1) {365 return carriageReturnIndex;366 }367 return Math.min(carriageReturnIndex, newlineIndex);368}369370function consumeLine(text: string): { line: string; rest: string } | null {371 const lineBreakIndex = nextLineBreakIndex(text);372 if (lineBreakIndex === -1) {373 return null;374 }375376 let nextIndex = lineBreakIndex + 1;377 if (text[lineBreakIndex] === "\r" && text[nextIndex] === "\n") {378 nextIndex += 1;379 }380381 return {382 line: text.slice(0, lineBreakIndex),383 rest: text.slice(nextIndex),384 };385}386387async function* iterateSseMessages(388 body: ReadableStream<Uint8Array>,389 signal?: AbortSignal,390): AsyncGenerator<ServerSentEvent> {391 const reader = body.getReader();392 const decoder = new TextDecoder();393 const state: SseDecoderState = { event: null, data: [], raw: [] };394 let buffer = "";395396 try {397 while (true) {398 if (signal?.aborted) {399 throw new Error("Request was aborted");400 }401402 const { value, done } = await reader.read();403 if (done) {404 break;405 }406407 buffer += decoder.decode(value, { stream: true });408 let consumed = consumeLine(buffer);409 while (consumed) {410 buffer = consumed.rest;411 const event = decodeSseLine(consumed.line, state);412 if (event) {413 yield event;414 }415 consumed = consumeLine(buffer);416 }417 }418419 buffer += decoder.decode();420 let consumed = consumeLine(buffer);421 while (consumed) {422 buffer = consumed.rest;423 const event = decodeSseLine(consumed.line, state);424 if (event) {425 yield event;426 }427 consumed = consumeLine(buffer);428 }429430 if (buffer.length > 0) {431 const event = decodeSseLine(buffer, state);432 if (event) {433 yield event;434 }435 }436437 const trailingEvent = flushSseEvent(state);438 if (trailingEvent) {439 yield trailingEvent;440 }441 } finally {442 reader.releaseLock();443 }444}445446async function* iterateAnthropicEvents(447 response: Response,448 signal?: AbortSignal,449): AsyncGenerator<RawMessageStreamEvent> {450 if (!response.body) {451 throw new Error("Attempted to iterate over an Anthropic response with no body");452 }453454 let sawMessageStart = false;455 let sawMessageEnd = false;456457 for await (const sse of iterateSseMessages(response.body, signal)) {458 if (sse.event === "error") {459 throw new Error(sse.data);460 }461462 if (!ANTHROPIC_MESSAGE_EVENTS.has(sse.event ?? "")) {463 continue;464 }465466 try {467 const event = parseJsonWithRepair<RawMessageStreamEvent>(sse.data);468 if (event.type === "message_start") {469 sawMessageStart = true;470 } else if (event.type === "message_stop") {471 sawMessageEnd = true;472 }473 yield event;474 } catch (error) {475 const message = error instanceof Error ? error.message : String(error);476 throw new Error(477 `Could not parse Anthropic SSE event ${sse.event}: ${message}; data=${sse.data}; raw=${sse.raw.join("\\n")}`,478 );479 }480 }481482 if (sawMessageStart && !sawMessageEnd) {483 throw new Error("Anthropic stream ended before message_stop");484 }485}486487export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (488 model: Model<"anthropic-messages">,489 context: Context,490 options?: AnthropicOptions,491): AssistantMessageEventStream => {492 const stream = new AssistantMessageEventStream();493494 (async () => {495 const output: AssistantMessage = {496 role: "assistant",497 content: [],498 api: model.api as Api,499 provider: model.provider,500 model: model.id,501 usage: {502 input: 0,503 output: 0,504 cacheRead: 0,505 cacheWrite: 0,506 totalTokens: 0,507 cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },508 },509 stopReason: "pending",510 timestamp: Date.now(),511 };512513 try {514 let client: Anthropic;515 let isOAuth: boolean;516517 if (options?.client) {518 client = options.client;519 isOAuth = false;520 } else {521 const apiKey = options?.apiKey;522 assertRequestAuth(model.provider, apiKey, options?.headers);523524 let copilotDynamicHeaders: Record<string, string> | undefined;525 if (model.provider === "github-copilot") {526 const hasImages = hasCopilotVisionInput(context.messages);527 copilotDynamicHeaders = buildCopilotDynamicHeaders({528 messages: context.messages,529 hasImages,530 });531 }532533 const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);534 const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;535536 const created = createClient(537 model,538 apiKey,539 options?.interleavedThinking ?? true,540 shouldUseFineGrainedToolStreamingBeta(model, context),541 options?.headers,542 copilotDynamicHeaders,543 cacheSessionId,544 );545 client = created.client;546 isOAuth = created.isOAuthToken;547 }548 let params = buildParams(model, context, isOAuth, options);549 const nextParams = await options?.onPayload?.(params, model);550 if (nextParams !== undefined) {551 params = nextParams as MessageCreateParamsStreaming;552 }553 const requestOptions = {554 ...(options?.signal ? { signal: options.signal } : {}),555 ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),556 maxRetries: 0,557 };558 const response = await retryProviderRequest(559 () => client.messages.create({ ...params, stream: true }, requestOptions).asResponse(),560 {561 maxRetries: options?.maxRetries,562 maxRetryDelayMs: options?.maxRetryDelayMs,563 signal: options?.signal,564 },565 );566 await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);567 stream.push({ type: "start", partial: output });568569 type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number };570 const blocks = output.content as Block[];571572 for await (const event of iterateAnthropicEvents(response, options?.signal)) {573 if (event.type === "message_start") {574 output.responseId = event.message.id;575 // Capture initial token usage from message_start event576 // This ensures we have input token counts even if the stream is aborted early577 output.usage.input = event.message.usage.input_tokens || 0;578 output.usage.output = event.message.usage.output_tokens || 0;579 output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0;580 output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0;581 output.usage.cacheWrite1h = event.message.usage.cache_creation?.ephemeral_1h_input_tokens || 0;582 // Anthropic doesn't provide total_tokens, compute from components583 output.usage.totalTokens =584 output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;585 calculateCost(model, output.usage);586 } else if (event.type === "content_block_start") {587 if (event.content_block.type === "text") {588 const block: Block = {589 type: "text",590 text: "",591 index: event.index,592 };593 output.content.push(block);594 stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });595 } else if (event.content_block.type === "thinking") {596 const block: Block = {597 type: "thinking",598 thinking: "",599 thinkingSignature: "",600 index: event.index,601 };602 output.content.push(block);603 stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output });604 } else if (event.content_block.type === "redacted_thinking") {605 const block: Block = {606 type: "thinking",607 thinking: "[Reasoning redacted]",608 thinkingSignature: event.content_block.data,609 redacted: true,610 index: event.index,611 };612 output.content.push(block);613 stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output });614 } else if (event.content_block.type === "tool_use") {615 const block: Block = {616 type: "toolCall",617 id: event.content_block.id,618 name: isOAuth619 ? fromClaudeCodeName(event.content_block.name, context.tools)620 : event.content_block.name,621 arguments: (event.content_block.input as Record<string, any>) ?? {},622 partialJson: "",623 index: event.index,624 };625 output.content.push(block);626 stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });627 }628 } else if (event.type === "content_block_delta") {629 if (event.delta.type === "text_delta") {630 const index = blocks.findIndex((b) => b.index === event.index);631 const block = blocks[index];632 if (block && block.type === "text") {633 block.text += event.delta.text;634 stream.push({635 type: "text_delta",636 contentIndex: index,637 delta: event.delta.text,638 partial: output,639 });640 }641 } else if (event.delta.type === "thinking_delta") {642 const index = blocks.findIndex((b) => b.index === event.index);643 const block = blocks[index];644 if (block && block.type === "thinking") {645 block.thinking += event.delta.thinking;646 stream.push({647 type: "thinking_delta",648 contentIndex: index,649 delta: event.delta.thinking,650 partial: output,651 });652 }653 } else if (event.delta.type === "input_json_delta") {654 const index = blocks.findIndex((b) => b.index === event.index);655 const block = blocks[index];656 if (block && block.type === "toolCall") {657 block.partialJson += event.delta.partial_json;658 block.arguments = parseStreamingJson(block.partialJson);659 stream.push({660 type: "toolcall_delta",661 contentIndex: index,662 delta: event.delta.partial_json,663 partial: output,664 });665 }666 } else if (event.delta.type === "signature_delta") {667 const index = blocks.findIndex((b) => b.index === event.index);668 const block = blocks[index];669 if (block && block.type === "thinking") {670 block.thinkingSignature = block.thinkingSignature || "";671 block.thinkingSignature += event.delta.signature;672 }673 }674 } else if (event.type === "content_block_stop") {675 const index = blocks.findIndex((b) => b.index === event.index);676 const block = blocks[index];677 if (block) {678 delete (block as any).index;679 if (block.type === "text") {680 stream.push({681 type: "text_end",682 contentIndex: index,683 content: block.text,684 partial: output,685 });686 } else if (block.type === "thinking") {687 stream.push({688 type: "thinking_end",689 contentIndex: index,690 content: block.thinking,691 partial: output,692 });693 } else if (block.type === "toolCall") {694 block.arguments = parseStreamingJson(block.partialJson);695 // Finalize in-place and strip the scratch buffer so replay only696 // carries parsed arguments.697 delete (block as { partialJson?: string }).partialJson;698 stream.push({699 type: "toolcall_end",700 contentIndex: index,701 toolCall: block,702 partial: output,703 });704 }705 }706 } else if (event.type === "message_delta") {707 if (event.delta.stop_reason) {708 const stopReasonResult = mapStopReason(event.delta.stop_reason, event.delta.stop_details);709 output.stopReason = stopReasonResult.stopReason;710 if (stopReasonResult.errorMessage) {711 output.errorMessage = stopReasonResult.errorMessage;712 }713 }714 // Only update usage fields if present (not null).715 // Preserves input_tokens from message_start when proxies omit it in message_delta.716 if (event.usage) {717 if (event.usage.input_tokens != null) {718 output.usage.input = event.usage.input_tokens;719 }720 if (event.usage.output_tokens != null) {721 output.usage.output = event.usage.output_tokens;722 }723 if (event.usage.cache_read_input_tokens != null) {724 output.usage.cacheRead = event.usage.cache_read_input_tokens;725 }726 if (event.usage.cache_creation_input_tokens != null) {727 output.usage.cacheWrite = event.usage.cache_creation_input_tokens;728 }729 // Anthropic reports reasoning tokens in `output_tokens_details.thinking_tokens` on the730 // final message_delta usage (a subset of output_tokens). SDK 0.91.1 omits the field from731 // its Usage type, so read it through a narrow cast. Verified against the live API.732 const thinkingTokens = (event.usage as { output_tokens_details?: { thinking_tokens?: number } })733 .output_tokens_details?.thinking_tokens;734 if (thinkingTokens != null) {735 output.usage.reasoning = thinkingTokens;736 }737 }738 // Anthropic doesn't provide total_tokens, compute from components739 output.usage.totalTokens =740 output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;741 calculateCost(model, output.usage);742 }743 }744745 if (options?.signal?.aborted) {746 throw new Error("Request was aborted");747 }748749 if (output.stopReason === "pending") {750 throw new Error("Anthropic stream ended without a stop reason");751 }752 if (output.stopReason === "aborted" || output.stopReason === "error") {753 throw new Error(output.errorMessage || "An unknown error occurred");754 }755756 stream.push({ type: "done", reason: output.stopReason, message: output });757 stream.end();758 } catch (error) {759 for (const block of output.content) {760 delete (block as { index?: number }).index;761 // partialJson is only a streaming scratch buffer; never persist it.762 delete (block as { partialJson?: string }).partialJson;763 }764 output.stopReason = options?.signal?.aborted ? "aborted" : "error";765 output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);766 stream.push({ type: "error", reason: output.stopReason, error: output });767 stream.end();768 }769 })();770771 return stream;772};773774/**775 * Map ThinkingLevel to Anthropic effort levels for adaptive thinking.776 * Note: effort "max" is available on all adaptive-thinking Claude models, while native777 * "xhigh" is only available on Opus 4.7/4.8, Sonnet 5, and Fable 5.778 */779function mapThinkingLevelToEffort(780 model: Model<"anthropic-messages">,781 level: SimpleStreamOptions["reasoning"],782): AnthropicEffort {783 const mapped = level ? model.thinkingLevelMap?.[level] : undefined;784 if (typeof mapped === "string") return mapped as AnthropicEffort;785786 switch (level) {787 case "minimal":788 case "low":789 return "low";790 case "medium":791 return "medium";792 case "high":793 return "high";794 default:795 return "high";796 }797}798799export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOptions> = (800 model: Model<"anthropic-messages">,801 context: Context,802 options?: SimpleStreamOptions,803): AssistantMessageEventStream => {804 assertRequestAuth(model.provider, options?.apiKey, options?.headers);805806 const base = buildBaseOptions(model, context, options, options?.apiKey);807 if (!options?.reasoning) {808 return stream(model, context, { ...base, thinkingEnabled: false } satisfies AnthropicOptions);809 }810811 // For models with adaptive thinking: use an effort level.812 // For older models: use budget-based thinking.813 if (model.compat?.forceAdaptiveThinking === true) {814 const effort = mapThinkingLevelToEffort(model, options.reasoning);815 return stream(model, context, {816 ...base,817 thinkingEnabled: true,818 effort,819 } satisfies AnthropicOptions);820 }821822 // Undefined means the caller did not request an output cap; let the helper use the model cap.823 // Do not coerce to 0 here, or the thinking budget would become the entire max_tokens value.824 const adjusted = adjustMaxTokensForThinking(825 base.maxTokens,826 model.maxTokens,827 options.reasoning,828 options.thinkingBudgets,829 );830831 const maxTokens = clampMaxTokensToContext(model, context, adjusted.maxTokens);832833 return stream(model, context, {834 ...base,835 maxTokens,836 thinkingEnabled: true,837 thinkingBudgetTokens: Math.min(adjusted.thinkingBudget, Math.max(0, maxTokens - 1024)),838 } satisfies AnthropicOptions);839};840841function isOAuthToken(apiKey: string): boolean {842 return apiKey.includes("sk-ant-oat");843}844845function createClient(846 model: Model<"anthropic-messages">,847 apiKey: string | undefined,848 interleavedThinking: boolean,849 useFineGrainedToolStreamingBeta: boolean,850 optionsHeaders?: ProviderHeaders,851 dynamicHeaders?: Record<string, string>,852 sessionId?: string,853): { client: Anthropic; isOAuthToken: boolean } {854 // Adaptive thinking models have interleaved thinking built in, so skip the beta header.855 const needsInterleavedBeta = interleavedThinking && model.compat?.forceAdaptiveThinking !== true;856 const betaFeatures: string[] = [];857 if (useFineGrainedToolStreamingBeta) {858 betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA);859 }860 if (needsInterleavedBeta) {861 betaFeatures.push(INTERLEAVED_THINKING_BETA);862 }863864 // Copilot: Bearer auth, selective betas.865 if (model.provider === "github-copilot") {866 const client = new Anthropic({867 apiKey: null,868 authToken: apiKey ?? null,869 baseURL: model.baseUrl,870 dangerouslyAllowBrowser: true,871 defaultHeaders: mergeHeaders(872 {873 accept: "application/json",874 "anthropic-dangerous-direct-browser-access": "true",875 ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),876 },877 model.headers,878 dynamicHeaders,879 optionsHeaders,880 ),881 });882883 return { client, isOAuthToken: false };884 }885886 // OAuth: Bearer auth, Claude Code identity headers887 if (apiKey && isOAuthToken(apiKey)) {888 const client = new Anthropic({889 apiKey: null,890 authToken: apiKey,891 baseURL: model.baseUrl,892 dangerouslyAllowBrowser: true,893 defaultHeaders: mergeHeaders(894 {895 accept: "application/json",896 "anthropic-dangerous-direct-browser-access": "true",897 "anthropic-beta": ["claude-code-20250219", "oauth-2025-04-20", ...betaFeatures].join(","),898 "user-agent": `claude-cli/${claudeCodeVersion}`,899 "x-app": "cli",900 },901 model.headers,902 optionsHeaders,903 ),904 });905906 return { client, isOAuthToken: true };907 }908909 // API key or header-owned auth.910 const sessionAffinityHeaders: ProviderHeaders =911 sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {};912 const defaultHeaders = mergeHeaders(913 {914 accept: "application/json",915 "anthropic-dangerous-direct-browser-access": "true",916 ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),917 },918 sessionAffinityHeaders,919 model.headers,920 optionsHeaders,921 );922 const client = new Anthropic({923 apiKey: apiKey ?? null,924 authToken: null,925 baseURL: model.baseUrl,926 dangerouslyAllowBrowser: true,927 defaultHeaders,928 });929930 return { client, isOAuthToken: false };931}932933function buildParams(934 model: Model<"anthropic-messages">,935 context: Context,936 isOAuthToken: boolean,937 options?: AnthropicOptions,938): MessageCreateParamsStreaming {939 const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);940 const compat = getAnthropicCompat(model);941 const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);942 const normalizeToolName = isOAuthToken ? toClaudeCodeName : (name: string) => name;943 const toolPlacement = splitDeferredTools(944 { ...context, messages: transformedMessages },945 compat.supportsToolReferences,946 normalizeToolName,947 );948 let immediateTools = toolPlacement.immediate;949 let deferredTools = [...toolPlacement.deferred.values()];950 if (immediateTools.length === 0 && deferredTools.length > 0) {951 immediateTools = deferredTools;952 deferredTools = [];953 }954 const deferredToolNames = new Set(deferredTools.map((tool) => normalizeToolName(tool.name)));955 const params: MessageCreateParamsStreaming = {956 model: model.id,957 messages: convertMessages(958 transformedMessages,959 isOAuthToken,960 cacheControl,961 compat.allowEmptySignature,962 deferredToolNames,963 normalizeToolName,964 ),965 max_tokens: options?.maxTokens ?? model.maxTokens,966 stream: true,967 };968969 // For OAuth tokens, we MUST include Claude Code identity970 if (isOAuthToken) {971 params.system = [972 {973 type: "text",974 text: "You are Claude Code, Anthropic's official CLI for Claude.",975 ...(cacheControl ? { cache_control: cacheControl } : {}),976 },977 ];978 if (context.systemPrompt) {979 params.system.push({980 type: "text",981 text: sanitizeSurrogates(context.systemPrompt),982 ...(cacheControl ? { cache_control: cacheControl } : {}),983 });984 }985 } else if (context.systemPrompt) {986 // Add cache control to system prompt for non-OAuth tokens987 params.system = [988 {989 type: "text",990 text: sanitizeSurrogates(context.systemPrompt),991 ...(cacheControl ? { cache_control: cacheControl } : {}),992 },993 ];994 }995996 // Temperature is incompatible with extended thinking and unsupported on Claude Opus 4.7+.997 if (options?.temperature !== undefined && !options?.thinkingEnabled && compat.supportsTemperature) {998 params.temperature = options.temperature;999 }10001001 if (immediateTools.length > 0 || deferredTools.length > 0) {1002 params.tools = [1003 ...convertTools(1004 immediateTools,1005 isOAuthToken,1006 compat.supportsEagerToolInputStreaming,1007 compat.supportsStrictTools,1008 compat.supportsCacheControlOnTools ? cacheControl : undefined,1009 ),1010 ...convertTools(1011 deferredTools,1012 isOAuthToken,1013 compat.supportsEagerToolInputStreaming,1014 compat.supportsStrictTools,1015 undefined,1016 true,1017 ),1018 ];1019 }10201021 // Configure thinking mode: adaptive, budget-based, or explicitly disabled.1022 if (model.reasoning) {1023 if (options?.thinkingEnabled) {1024 // Default to "summarized" so Opus 4.7 and Mythos Preview behave like1025 // older Claude 4 models (whose API default is also "summarized").1026 const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized";1027 if (model.compat?.forceAdaptiveThinking === true) {1028 // Adaptive thinking: Claude decides when and how much to think.1029 params.thinking = { type: "adaptive", display };1030 if (options.effort) {1031 // The Anthropic SDK types can lag newly supported effort values such as "xhigh".1032 params.output_config =1033 options.effort === "xhigh"1034 ? ({ effort: options.effort } as unknown as NonNullable<1035 MessageCreateParamsStreaming["output_config"]1036 >)1037 : { effort: options.effort };1038 }1039 } else {1040 // Budget-based thinking for older models1041 params.thinking = {1042 type: "enabled",1043 budget_tokens: options.thinkingBudgetTokens || 1024,1044 display,1045 };1046 }1047 } else if (options?.thinkingEnabled === false && model.thinkingLevelMap?.off !== null) {1048 params.thinking = { type: "disabled" };1049 }1050 }10511052 if (options?.metadata) {1053 const userId = options.metadata.user_id;1054 if (typeof userId === "string") {1055 params.metadata = { user_id: userId };1056 }1057 }10581059 if (options?.toolChoice) {1060 if (typeof options.toolChoice === "string") {1061 params.tool_choice = { type: options.toolChoice };1062 } else {1063 params.tool_choice = options.toolChoice;1064 }1065 }10661067 return params;1068}10691070// Normalize tool call IDs to match Anthropic's required pattern and length1071function normalizeToolCallId(id: string): string {1072 return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);1073}10741075function convertToolResult(1076 msg: ToolResultMessage,1077 isOAuthToken: boolean,1078 deferredToolNames: ReadonlySet<string>,1079 loadedToolNames: Set<string>,1080 normalizeToolName: (name: string) => string,1081): { toolResult: ContentBlockParam; siblingContent: ContentBlockParam[] } {1082 const references: Array<{ type: "tool_reference"; tool_name: string }> = [];1083 for (const name of msg.addedToolNames ?? []) {1084 const normalizedName = normalizeToolName(name);1085 if (!deferredToolNames.has(normalizedName) || loadedToolNames.has(normalizedName)) continue;1086 loadedToolNames.add(normalizedName);1087 references.push({1088 type: "tool_reference",1089 tool_name: isOAuthToken ? toClaudeCodeName(name) : name,1090 });1091 }1092 const convertedContent = convertContentBlocks(msg.content);1093 // Anthropic rejects tool references mixed with ordinary tool-result content.1094 return {1095 toolResult: {1096 type: "tool_result",1097 tool_use_id: msg.toolCallId,1098 content: references.length > 0 ? references : convertedContent,1099 is_error: msg.isError,1100 },1101 siblingContent:1102 references.length === 01103 ? []1104 : typeof convertedContent === "string"1105 ? [{ type: "text", text: convertedContent }]1106 : convertedContent,1107 };1108}11091110function convertMessages(1111 transformedMessages: Message[],1112 isOAuthToken: boolean,1113 cacheControl?: CacheControlEphemeral,1114 allowEmptySignature = false,1115 deferredToolNames: ReadonlySet<string> = new Set(),1116 normalizeToolName: (name: string) => string = (name) => name,1117): MessageParam[] {1118 const params: MessageParam[] = [];1119 const loadedToolNames = new Set<string>();11201121 for (let i = 0; i < transformedMessages.length; i++) {1122 const msg = transformedMessages[i];11231124 if (msg.role === "user") {1125 if (typeof msg.content === "string") {1126 if (msg.content.trim().length > 0) {1127 params.push({1128 role: "user",1129 content: sanitizeSurrogates(msg.content),1130 });1131 }1132 } else {1133 const blocks: ContentBlockParam[] = msg.content.map((item) => {1134 if (item.type === "text") {1135 return {1136 type: "text",1137 text: sanitizeSurrogates(item.text),1138 };1139 } else {1140 return {1141 type: "image",1142 source: {1143 type: "base64",1144 media_type: item.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp",1145 data: item.data,1146 },1147 };1148 }1149 });1150 const filteredBlocks = blocks.filter((b) => {1151 if (b.type === "text") {1152 return b.text.trim().length > 0;1153 }1154 return true;1155 });1156 if (filteredBlocks.length === 0) continue;1157 params.push({1158 role: "user",1159 content: filteredBlocks,1160 });1161 }1162 } else if (msg.role === "assistant") {1163 const blocks: ContentBlockParam[] = [];11641165 for (const block of msg.content) {1166 if (block.type === "text") {1167 if (block.text.trim().length === 0) continue;1168 blocks.push({1169 type: "text",1170 text: sanitizeSurrogates(block.text),1171 });1172 } else if (block.type === "thinking") {1173 // Redacted thinking: pass the opaque payload back as redacted_thinking1174 if (block.redacted) {1175 blocks.push({1176 type: "redacted_thinking",1177 data: block.thinkingSignature!,1178 });1179 continue;1180 }1181 const thinkingSignature = block.thinkingSignature;1182 const hasThinkingSignature = !!thinkingSignature && thinkingSignature.trim().length > 0;1183 if (block.thinking.trim().length === 0 && !hasThinkingSignature) continue;1184 // If thinking signature is missing/empty (e.g., from aborted stream),1185 // convert to plain text for Anthropic. Some compatible providers emit1186 // and accept empty signatures, so let marked models preserve the block.1187 if (!hasThinkingSignature) {1188 blocks.push(1189 allowEmptySignature1190 ? {1191 type: "thinking",1192 thinking: sanitizeSurrogates(block.thinking),1193 signature: "",1194 }1195 : {1196 type: "text",1197 text: sanitizeSurrogates(block.thinking),1198 },1199 );1200 } else {1201 blocks.push({1202 type: "thinking",1203 thinking: sanitizeSurrogates(block.thinking),1204 signature: thinkingSignature,1205 });1206 }1207 } else if (block.type === "toolCall") {1208 blocks.push({1209 type: "tool_use",1210 id: block.id,1211 name: isOAuthToken ? toClaudeCodeName(block.name) : block.name,1212 input: block.arguments ?? {},1213 });1214 }1215 }1216 if (blocks.length === 0) continue;1217 params.push({1218 role: "assistant",1219 content: blocks,1220 });1221 } else if (msg.role === "toolResult") {1222 // Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint.1223 const toolResults: ContentBlockParam[] = [];1224 const siblingContent: ContentBlockParam[] = [];1225 let j = i;1226 while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") {1227 const converted = convertToolResult(1228 transformedMessages[j] as ToolResultMessage,1229 isOAuthToken,1230 deferredToolNames,1231 loadedToolNames,1232 normalizeToolName,1233 );1234 toolResults.push(converted.toolResult);1235 siblingContent.push(...converted.siblingContent);1236 j++;1237 }12381239 // Skip the messages we've already processed.1240 i = j - 1;12411242 // Displaced reference-bearing results must follow every tool_result block.1243 params.push({1244 role: "user",1245 content: [...toolResults, ...siblingContent],1246 });1247 }1248 }12491250 // Add cache_control to the last user message to cache conversation history1251 if (cacheControl && params.length > 0) {1252 const lastMessage = params[params.length - 1];1253 if (lastMessage.role === "user") {1254 if (Array.isArray(lastMessage.content)) {1255 const lastBlock = lastMessage.content[lastMessage.content.length - 1];1256 if (1257 lastBlock &&1258 (lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result")1259 ) {1260 (lastBlock as any).cache_control = cacheControl;1261 }1262 } else if (typeof lastMessage.content === "string") {1263 lastMessage.content = [1264 {1265 type: "text",1266 text: lastMessage.content,1267 cache_control: cacheControl,1268 },1269 ] as any;1270 }1271 }1272 }12731274 return params;1275}12761277function shouldUseFineGrainedToolStreamingBeta(model: Model<"anthropic-messages">, context: Context): boolean {1278 return !!context.tools?.length && !getAnthropicCompat(model).supportsEagerToolInputStreaming;1279}12801281function convertTools(1282 tools: Tool[],1283 isOAuthToken: boolean,1284 supportsEagerToolInputStreaming: boolean,1285 supportsStrictTools: boolean,1286 cacheControl?: CacheControlEphemeral,1287 deferLoading = false,1288): Anthropic.Messages.Tool[] {1289 if (!tools) return [];12901291 return tools.map((tool, index) => {1292 const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictTools);1293 const schema = tool.parameters as { properties?: unknown; required?: string[] };1294 const legacyInputSchema = {1295 type: "object" as const,1296 properties: schema.properties ?? {},1297 required: schema.required ?? [],1298 };1299 const inputSchema =1300 strict === true1301 ? {1302 ...(tool.parameters as Record<string, unknown>),1303 ...legacyInputSchema,1304 }1305 : legacyInputSchema;13061307 return {1308 name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,1309 description: tool.description,1310 ...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}),1311 ...(strict === true ? { strict: true } : {}),1312 input_schema: inputSchema,1313 ...(deferLoading ? { defer_loading: true } : {}),1314 ...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}),1315 };1316 });1317}13181319function mapStopReason(1320 reason: Anthropic.Messages.StopReason | string,1321 stopDetails?: RefusalStopDetails | null,1322): { stopReason: StopReason; errorMessage?: string } {1323 switch (reason) {1324 case "end_turn":1325 return { stopReason: "stop" };1326 case "max_tokens":1327 return { stopReason: "length" };1328 case "tool_use":1329 return { stopReason: "toolUse" };1330 case "refusal":1331 return {1332 stopReason: "error",1333 errorMessage: stopDetails?.explanation || `The model refused to complete the request`,1334 };1335 case "pause_turn": // Stop is good enough -> resubmit1336 return { stopReason: "stop" };1337 case "stop_sequence":1338 return { stopReason: "stop" }; // We don't supply stop sequences, so this should never happen1339 case "sensitive": // Content flagged by safety filters (not yet in SDK types)1340 return { stopReason: "error" };1341 default:1342 // Handle unknown stop reasons gracefully (API may add new values)1343 throw new Error(`Unhandled stop reason: ${reason}`);1344 }1345}1346