Buckets:
| import { Effect, Schema } from "effect" | |
| import { Route } from "../route/client" | |
| import { Auth } from "../route/auth" | |
| import { Endpoint } from "../route/endpoint" | |
| import { Framing } from "../route/framing" | |
| import { Protocol } from "../route/protocol" | |
| import { | |
| LLMEvent, | |
| Usage, | |
| type CacheHint, | |
| type FinishReason, | |
| type LLMRequest, | |
| type MediaPart, | |
| type ProviderMetadata, | |
| type ToolCallPart, | |
| type ToolDefinition, | |
| type ToolContent, | |
| type ToolResultPart, | |
| } from "../schema" | |
| import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" | |
| import { isContextOverflow } from "../provider-error" | |
| import * as Cache from "./utils/cache" | |
| import { Lifecycle } from "./utils/lifecycle" | |
| import { ToolStream } from "./utils/tool-stream" | |
| const ADAPTER = "anthropic-messages" | |
| export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1" | |
| export const PATH = "/messages" | |
| // ============================================================================= | |
| // Request Body Schema | |
| // ============================================================================= | |
| const AnthropicCacheControl = Schema.Struct({ | |
| type: Schema.tag("ephemeral"), | |
| ttl: Schema.optional(Schema.Literals(["5m", "1h"])), | |
| }) | |
| const AnthropicTextBlock = Schema.Struct({ | |
| type: Schema.tag("text"), | |
| text: Schema.String, | |
| cache_control: Schema.optional(AnthropicCacheControl), | |
| }) | |
| type AnthropicTextBlock = Schema.Schema.Type<typeof AnthropicTextBlock> | |
| const AnthropicImageBlock = Schema.Struct({ | |
| type: Schema.tag("image"), | |
| source: Schema.Struct({ | |
| type: Schema.tag("base64"), | |
| media_type: Schema.String, | |
| data: Schema.String, | |
| }), | |
| cache_control: Schema.optional(AnthropicCacheControl), | |
| }) | |
| type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock> | |
| const AnthropicThinkingBlock = Schema.Struct({ | |
| type: Schema.tag("thinking"), | |
| thinking: Schema.String, | |
| signature: Schema.optional(Schema.String), | |
| cache_control: Schema.optional(AnthropicCacheControl), | |
| }) | |
| const AnthropicToolUseBlock = Schema.Struct({ | |
| type: Schema.tag("tool_use"), | |
| id: Schema.String, | |
| name: Schema.String, | |
| input: Schema.Unknown, | |
| cache_control: Schema.optional(AnthropicCacheControl), | |
| }) | |
| type AnthropicToolUseBlock = Schema.Schema.Type<typeof AnthropicToolUseBlock> | |
| const AnthropicServerToolUseBlock = Schema.Struct({ | |
| type: Schema.tag("server_tool_use"), | |
| id: Schema.String, | |
| name: Schema.String, | |
| input: Schema.Unknown, | |
| cache_control: Schema.optional(AnthropicCacheControl), | |
| }) | |
| type AnthropicServerToolUseBlock = Schema.Schema.Type<typeof AnthropicServerToolUseBlock> | |
| // Server tool result blocks: web_search_tool_result, code_execution_tool_result, | |
| // and web_fetch_tool_result. The provider executes the tool and inlines the | |
| // structured result into the assistant turn — there is no client tool_result | |
| // round-trip. We round-trip the structured `content` payload as opaque JSON so | |
| // the next request can echo it back when continuing the conversation. | |
| const AnthropicServerToolResultType = Schema.Literals([ | |
| "web_search_tool_result", | |
| "code_execution_tool_result", | |
| "web_fetch_tool_result", | |
| ]) | |
| type AnthropicServerToolResultType = Schema.Schema.Type<typeof AnthropicServerToolResultType> | |
| const AnthropicServerToolResultBlock = Schema.Struct({ | |
| type: AnthropicServerToolResultType, | |
| tool_use_id: Schema.String, | |
| content: Schema.Unknown, | |
| cache_control: Schema.optional(AnthropicCacheControl), | |
| }) | |
| type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock> | |
| // Anthropic accepts either a plain string or an ordered array of text/image | |
| // blocks inside `tool_result.content`. The array form is required when a tool | |
| // returns image bytes (screenshot, image search, etc.) so they can be passed | |
| // to the model as proper image inputs instead of being JSON-stringified into | |
| // the prompt — which silently inflates context by megabytes and can push the | |
| // conversation over the model's token limit. | |
| const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock]) | |
| const AnthropicToolResultBlock = Schema.Struct({ | |
| type: Schema.tag("tool_result"), | |
| tool_use_id: Schema.String, | |
| content: Schema.Union([Schema.String, Schema.Array(AnthropicToolResultContent)]), | |
| is_error: Schema.optional(Schema.Boolean), | |
| cache_control: Schema.optional(AnthropicCacheControl), | |
| }) | |
| const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock]) | |
| type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock> | |
| const AnthropicAssistantBlock = Schema.Union([ | |
| AnthropicTextBlock, | |
| AnthropicThinkingBlock, | |
| AnthropicToolUseBlock, | |
| AnthropicServerToolUseBlock, | |
| AnthropicServerToolResultBlock, | |
| ]) | |
| type AnthropicAssistantBlock = Schema.Schema.Type<typeof AnthropicAssistantBlock> | |
| type AnthropicToolResultBlock = Schema.Schema.Type<typeof AnthropicToolResultBlock> | |
| const AnthropicMessage = Schema.Union([ | |
| Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(AnthropicUserBlock) }), | |
| Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(AnthropicAssistantBlock) }), | |
| Schema.Struct({ role: Schema.Literal("system"), content: Schema.Array(AnthropicTextBlock) }), | |
| ]).pipe(Schema.toTaggedUnion("role")) | |
| type AnthropicMessage = Schema.Schema.Type<typeof AnthropicMessage> | |
| const AnthropicTool = Schema.Struct({ | |
| name: Schema.String, | |
| description: Schema.String, | |
| input_schema: JsonObject, | |
| cache_control: Schema.optional(AnthropicCacheControl), | |
| }) | |
| type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool> | |
| const AnthropicToolChoice = Schema.Union([ | |
| Schema.Struct({ type: Schema.Literals(["auto", "any"]) }), | |
| Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), | |
| ]) | |
| const AnthropicThinking = Schema.Struct({ | |
| type: Schema.tag("enabled"), | |
| budget_tokens: Schema.Number, | |
| }) | |
| const AnthropicBodyFields = { | |
| model: Schema.String, | |
| system: optionalArray(AnthropicTextBlock), | |
| messages: Schema.Array(AnthropicMessage), | |
| tools: optionalArray(AnthropicTool), | |
| tool_choice: Schema.optional(AnthropicToolChoice), | |
| stream: Schema.Literal(true), | |
| max_tokens: Schema.Number, | |
| temperature: Schema.optional(Schema.Number), | |
| top_p: Schema.optional(Schema.Number), | |
| top_k: Schema.optional(Schema.Number), | |
| stop_sequences: optionalArray(Schema.String), | |
| thinking: Schema.optional(AnthropicThinking), | |
| } | |
| const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) | |
| export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody> | |
| const AnthropicUsage = Schema.Struct({ | |
| input_tokens: Schema.optional(Schema.Number), | |
| output_tokens: Schema.optional(Schema.Number), | |
| cache_creation_input_tokens: optionalNull(Schema.Number), | |
| cache_read_input_tokens: optionalNull(Schema.Number), | |
| }) | |
| type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage> | |
| const AnthropicStreamBlock = Schema.Struct({ | |
| type: Schema.String, | |
| id: Schema.optional(Schema.String), | |
| name: Schema.optional(Schema.String), | |
| text: Schema.optional(Schema.String), | |
| thinking: Schema.optional(Schema.String), | |
| signature: Schema.optional(Schema.String), | |
| input: Schema.optional(Schema.Unknown), | |
| // *_tool_result blocks arrive whole as content_block_start (no streaming | |
| // delta) with the structured payload in `content` and the originating | |
| // server_tool_use id in `tool_use_id`. | |
| tool_use_id: Schema.optional(Schema.String), | |
| content: Schema.optional(Schema.Unknown), | |
| }) | |
| const AnthropicStreamDelta = Schema.Struct({ | |
| type: Schema.optional(Schema.String), | |
| text: Schema.optional(Schema.String), | |
| thinking: Schema.optional(Schema.String), | |
| partial_json: Schema.optional(Schema.String), | |
| signature: Schema.optional(Schema.String), | |
| stop_reason: optionalNull(Schema.String), | |
| stop_sequence: optionalNull(Schema.String), | |
| }) | |
| const AnthropicEvent = Schema.Struct({ | |
| type: Schema.String, | |
| index: Schema.optional(Schema.Number), | |
| message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })), | |
| content_block: Schema.optional(AnthropicStreamBlock), | |
| delta: Schema.optional(AnthropicStreamDelta), | |
| usage: Schema.optional(AnthropicUsage), | |
| // `type` and `message` are both required per Anthropic's spec, but | |
| // OpenAI-compatible proxies and gateway translations occasionally drop one | |
| // or the other; mark them optional so a partial payload still parses and | |
| // the parser can fall back to whichever field is populated. | |
| error: Schema.optional( | |
| Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }), | |
| ), | |
| }) | |
| type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent> | |
| interface ParserState { | |
| readonly tools: ToolStream.State<number> | |
| readonly usage?: Usage | |
| readonly lifecycle: Lifecycle.State | |
| } | |
| const invalid = ProviderShared.invalidRequest | |
| // ============================================================================= | |
| // Request Lowering | |
| // ============================================================================= | |
| // Anthropic accepts at most 4 explicit cache_control breakpoints per request, | |
| // across `tools`, `system`, and `messages`. Beyond the cap the API returns a | |
| // 400 — so the lowering layer counts emitted markers and silently drops any | |
| // that exceed it. | |
| const ANTHROPIC_BREAKPOINT_CAP = 4 | |
| const EPHEMERAL_5M = { type: "ephemeral" as const } | |
| const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const } | |
| const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => { | |
| if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined | |
| if (breakpoints.remaining <= 0) { | |
| breakpoints.dropped += 1 | |
| return undefined | |
| } | |
| breakpoints.remaining -= 1 | |
| return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M | |
| } | |
| const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata }) | |
| const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => { | |
| const anthropic = metadata?.anthropic | |
| if (!ProviderShared.isRecord(anthropic)) return undefined | |
| return typeof anthropic.signature === "string" ? anthropic.signature : undefined | |
| } | |
| const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({ | |
| name: tool.name, | |
| description: tool.description, | |
| input_schema: tool.inputSchema, | |
| cache_control: cacheControl(breakpoints, tool.cache), | |
| }) | |
| const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) => | |
| ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, { | |
| auto: () => ({ type: "auto" as const }), | |
| none: () => undefined, | |
| required: () => ({ type: "any" as const }), | |
| tool: (name) => ({ type: "tool" as const, name }), | |
| }) | |
| const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({ | |
| type: "tool_use", | |
| id: part.id, | |
| name: part.name, | |
| input: part.input, | |
| }) | |
| const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({ | |
| type: "server_tool_use", | |
| id: part.id, | |
| name: part.name, | |
| input: part.input, | |
| }) | |
| // Server tool result blocks are typed by name. Anthropic ships three today; | |
| // extend this list when new server tools land. The block content is the | |
| // structured payload returned by the provider, which we round-trip as-is. | |
| const serverToolResultType = (name: string): AnthropicServerToolResultType | undefined => { | |
| if (name === "web_search") return "web_search_tool_result" | |
| if (name === "code_execution") return "code_execution_tool_result" | |
| if (name === "web_fetch") return "web_fetch_tool_result" | |
| return undefined | |
| } | |
| const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (part: ToolResultPart) { | |
| const wireType = serverToolResultType(part.name) | |
| if (!wireType) | |
| return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`) | |
| return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock | |
| }) | |
| const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) { | |
| const media = yield* ProviderShared.validateMedia( | |
| "Anthropic Messages", | |
| part, | |
| new Set<string>(ProviderShared.IMAGE_MIMES), | |
| ) | |
| return { | |
| type: "image" as const, | |
| source: { | |
| type: "base64" as const, | |
| media_type: media.mime, | |
| data: media.base64, | |
| }, | |
| } satisfies AnthropicImageBlock | |
| }) | |
| // Tool results may carry structured text/images. Keep media as provider-native | |
| // content instead of JSON-stringifying base64 into a prompt string. | |
| const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* ( | |
| item: ToolContent, | |
| ) { | |
| if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock | |
| const media = yield* ProviderShared.validateToolFile( | |
| "Anthropic Messages", | |
| item, | |
| new Set<string>(ProviderShared.IMAGE_MIMES), | |
| ) | |
| return { | |
| type: "image" as const, | |
| source: { | |
| type: "base64" as const, | |
| media_type: media.mime, | |
| data: media.base64, | |
| }, | |
| } satisfies AnthropicImageBlock | |
| }) | |
| const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) { | |
| // Text / json / error results stay as a string for backward compatibility | |
| // with existing cassettes and provider expectations. | |
| if (part.result.type !== "content") return ProviderShared.toolResultText(part) | |
| // Preserve the narrowed array element type when compiled through a consumer package. | |
| const content: ReadonlyArray<ToolContent> = part.result.value | |
| return yield* Effect.forEach(content, lowerToolResultContentItem) | |
| }) | |
| // Mid-conversation system messages are a native Claude API feature only for | |
| // Opus 4.8. Other Anthropic models intentionally use the same visible wrapped- | |
| // user fallback as non-Anthropic routes rather than sending a role they reject. | |
| const supportsNativeSystemUpdates = (request: LLMRequest) => String(request.model.id) === "claude-opus-4-8" | |
| const endsInServerToolUse = (message: LLMRequest["messages"][number]) => { | |
| const last = message.content.at(-1) | |
| return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true | |
| } | |
| const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => { | |
| const previous = messages[index - 1] | |
| const next = messages[index + 1] | |
| return ( | |
| previous !== undefined && | |
| previous.role !== "system" && | |
| (previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) && | |
| next?.role !== "system" && | |
| (next === undefined || next.role === "assistant") | |
| ) | |
| } | |
| const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => { | |
| const pending = new Set<string>() | |
| for (const message of messages.slice(0, index)) { | |
| for (const part of message.content) { | |
| if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true) | |
| pending.add(part.id) | |
| if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id) | |
| } | |
| } | |
| return pending.size > 0 | |
| } | |
| const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* ( | |
| message: LLMRequest["messages"][number], | |
| breakpoints: Cache.Breakpoints, | |
| ) { | |
| const content = yield* ProviderShared.systemUpdateText("Anthropic Messages", message) | |
| return { | |
| role: "system" as const, | |
| content: content.map((part) => ({ | |
| type: "text" as const, | |
| text: part.text, | |
| cache_control: cacheControl(breakpoints, part.cache), | |
| })), | |
| } | |
| }) | |
| const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( | |
| request: LLMRequest, | |
| breakpoints: Cache.Breakpoints, | |
| ) { | |
| const messages: AnthropicMessage[] = [] | |
| for (const [index, message] of request.messages.entries()) { | |
| if (message.role === "system") { | |
| if (splitsLocalToolResults(request.messages, index)) | |
| return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result") | |
| if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) { | |
| messages.push(yield* lowerNativeSystemUpdate(message, breakpoints)) | |
| continue | |
| } | |
| const part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", message) | |
| const block = { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) } | |
| const previous = messages.at(-1) | |
| if (previous?.role === "user") | |
| messages[messages.length - 1] = { role: "user", content: [...previous.content, block] } | |
| else messages.push({ role: "user", content: [block] }) | |
| continue | |
| } | |
| if (message.role === "user") { | |
| const content: AnthropicUserBlock[] = [] | |
| for (const part of message.content) { | |
| if (part.type === "text") { | |
| content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) | |
| continue | |
| } | |
| if (part.type === "media") { | |
| content.push(yield* lowerImage(part)) | |
| continue | |
| } | |
| return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"]) | |
| } | |
| messages.push({ role: "user", content }) | |
| continue | |
| } | |
| if (message.role === "assistant") { | |
| const content: AnthropicAssistantBlock[] = [] | |
| for (const part of message.content) { | |
| if (part.type === "text") { | |
| content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) | |
| continue | |
| } | |
| if (part.type === "reasoning") { | |
| content.push({ | |
| type: "thinking", | |
| thinking: part.text, | |
| signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata), | |
| }) | |
| continue | |
| } | |
| if (part.type === "tool-call") { | |
| content.push(part.providerExecuted ? lowerServerToolCall(part) : lowerToolCall(part)) | |
| continue | |
| } | |
| if (part.type === "tool-result" && part.providerExecuted) { | |
| content.push(yield* lowerServerToolResult(part)) | |
| continue | |
| } | |
| return yield* invalid( | |
| `Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`, | |
| ) | |
| } | |
| messages.push({ role: "assistant", content }) | |
| continue | |
| } | |
| const content: AnthropicToolResultBlock[] = [] | |
| for (const part of message.content) { | |
| if (!ProviderShared.supportsContent(part, ["tool-result"])) | |
| return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"]) | |
| content.push({ | |
| type: "tool_result", | |
| tool_use_id: part.id, | |
| content: yield* lowerToolResultContent(part), | |
| is_error: part.result.type === "error" ? true : undefined, | |
| cache_control: cacheControl(breakpoints, part.cache), | |
| }) | |
| } | |
| messages.push({ role: "user", content }) | |
| } | |
| return messages | |
| }) | |
| const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic | |
| const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) { | |
| const thinking = anthropicOptions(request)?.thinking | |
| if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined | |
| const budget = | |
| typeof thinking.budgetTokens === "number" | |
| ? thinking.budgetTokens | |
| : typeof thinking.budget_tokens === "number" | |
| ? thinking.budget_tokens | |
| : undefined | |
| if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens") | |
| return { type: "enabled" as const, budget_tokens: budget } | |
| }) | |
| const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { | |
| const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined | |
| const generation = request.generation | |
| // Allocate the 4-breakpoint budget in invalidation order: tools → system → | |
| // messages. Tools live highest in the cache hierarchy, so when callers | |
| // over-mark we keep their tool hints and shed the message-tail ones first. | |
| const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) | |
| const tools = | |
| request.tools.length === 0 || request.toolChoice?.type === "none" | |
| ? undefined | |
| : request.tools.map((tool) => lowerTool(breakpoints, tool)) | |
| const system = | |
| request.system.length === 0 | |
| ? undefined | |
| : request.system.map((part) => ({ | |
| type: "text" as const, | |
| text: part.text, | |
| cache_control: cacheControl(breakpoints, part.cache), | |
| })) | |
| const messages = yield* lowerMessages(request, breakpoints) | |
| if (breakpoints.dropped > 0) { | |
| yield* Effect.logWarning( | |
| `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, | |
| ) | |
| } | |
| return { | |
| model: request.model.id, | |
| system, | |
| messages, | |
| tools, | |
| tool_choice: toolChoice, | |
| stream: true as const, | |
| max_tokens: generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 4096, | |
| temperature: generation?.temperature, | |
| top_p: generation?.topP, | |
| top_k: generation?.topK, | |
| stop_sequences: generation?.stop, | |
| thinking: yield* lowerThinking(request), | |
| } | |
| }) | |
| // ============================================================================= | |
| // Stream Parsing | |
| // ============================================================================= | |
| const mapFinishReason = (reason: string | null | undefined): FinishReason => { | |
| if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop" | |
| if (reason === "max_tokens") return "length" | |
| if (reason === "tool_use") return "tool-calls" | |
| if (reason === "refusal") return "content-filter" | |
| return "unknown" | |
| } | |
| // Anthropic reports the non-overlapping breakdown natively — its | |
| // `input_tokens` is the *non-cached* count per the Messages API docs, with | |
| // cache reads and writes as separate fields. We sum them to derive the | |
| // inclusive `inputTokens` the rest of the contract expects. Extended | |
| // thinking tokens are *not* broken out by Anthropic — they're billed as | |
| // part of `output_tokens`, so `reasoningTokens` stays `undefined` and | |
| // `outputTokens` carries the combined total. | |
| const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { | |
| if (!usage) return undefined | |
| const nonCached = usage.input_tokens | |
| const cacheRead = usage.cache_read_input_tokens ?? undefined | |
| const cacheWrite = usage.cache_creation_input_tokens ?? undefined | |
| const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite) | |
| return new Usage({ | |
| inputTokens, | |
| outputTokens: usage.output_tokens, | |
| nonCachedInputTokens: nonCached, | |
| cacheReadInputTokens: cacheRead, | |
| cacheWriteInputTokens: cacheWrite, | |
| totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined), | |
| providerMetadata: { anthropic: usage }, | |
| }) | |
| } | |
| // Anthropic emits usage on `message_start` and again on `message_delta` — the | |
| // final delta carries the authoritative totals. Right-biased merge: each | |
| // field prefers `right` when defined, falls back to `left`. `inputTokens` is | |
| // recomputed from the merged breakdown so the inclusive total stays | |
| // consistent with `nonCached + cacheRead + cacheWrite`. | |
| const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => { | |
| if (!left) return right | |
| if (!right) return left | |
| const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens | |
| const cacheReadInputTokens = right.cacheReadInputTokens ?? left.cacheReadInputTokens | |
| const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens | |
| const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens) | |
| const outputTokens = right.outputTokens ?? left.outputTokens | |
| return new Usage({ | |
| inputTokens, | |
| outputTokens, | |
| nonCachedInputTokens, | |
| cacheReadInputTokens, | |
| cacheWriteInputTokens, | |
| totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), | |
| providerMetadata: { | |
| anthropic: { | |
| ...left.providerMetadata?.["anthropic"], | |
| ...right.providerMetadata?.["anthropic"], | |
| }, | |
| }, | |
| }) | |
| } | |
| // Server tool result blocks come whole in `content_block_start` (no streaming | |
| // delta sequence). We convert the payload to a `tool-result` event with | |
| // `providerExecuted: true`. The runtime appends it to the assistant message | |
| // for round-trip; downstream consumers can inspect `result.value` for the | |
| // structured payload. | |
| const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> = { | |
| web_search_tool_result: "web_search", | |
| code_execution_tool_result: "code_execution", | |
| web_fetch_tool_result: "web_fetch", | |
| } | |
| const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES | |
| const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"]>): LLMEvent | undefined => { | |
| if (!block.type || !isServerToolResultType(block.type)) return undefined | |
| const errorPayload = | |
| typeof block.content === "object" && block.content !== null && "type" in block.content | |
| ? String((block.content as Record<string, unknown>).type) | |
| : "" | |
| const isError = errorPayload.endsWith("_tool_result_error") | |
| return LLMEvent.toolResult({ | |
| id: block.tool_use_id ?? "", | |
| name: SERVER_TOOL_RESULT_NAMES[block.type], | |
| result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content }, | |
| providerExecuted: true, | |
| providerMetadata: anthropicMetadata({ blockType: block.type }), | |
| }) | |
| } | |
| type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>] | |
| const NO_EVENTS: StepResult["1"] = [] | |
| const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => { | |
| const usage = mapUsage(event.message?.usage) | |
| return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS] | |
| } | |
| const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepResult => { | |
| const block = event.content_block | |
| if (!block) return [state, NO_EVENTS] | |
| if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) { | |
| const events: LLMEvent[] = [] | |
| const lifecycle = Lifecycle.stepStart(state.lifecycle, events) | |
| return [ | |
| { | |
| ...state, | |
| lifecycle, | |
| tools: ToolStream.start(state.tools, event.index, { | |
| id: block.id ?? String(event.index), | |
| name: block.name ?? "", | |
| providerExecuted: block.type === "server_tool_use", | |
| }), | |
| }, | |
| [...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })], | |
| ] | |
| } | |
| if (block.type === "text" && block.text) { | |
| const events: LLMEvent[] = [] | |
| return [ | |
| { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) }, | |
| events, | |
| ] | |
| } | |
| if (block.type === "thinking" && block.thinking) { | |
| const events: LLMEvent[] = [] | |
| return [ | |
| { | |
| ...state, | |
| lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking), | |
| }, | |
| events, | |
| ] | |
| } | |
| const result = serverToolResultEvent(block) | |
| if (!result) return [state, NO_EVENTS] | |
| const events: LLMEvent[] = [] | |
| return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]] | |
| } | |
| const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* ( | |
| state: ParserState, | |
| event: AnthropicEvent, | |
| ) { | |
| const delta = event.delta | |
| if (delta?.type === "text_delta" && delta.text) { | |
| const events: LLMEvent[] = [] | |
| return [ | |
| { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) }, | |
| events, | |
| ] satisfies StepResult | |
| } | |
| if (delta?.type === "thinking_delta" && delta.thinking) { | |
| const events: LLMEvent[] = [] | |
| return [ | |
| { | |
| ...state, | |
| lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, delta.thinking), | |
| }, | |
| events, | |
| ] satisfies StepResult | |
| } | |
| if (delta?.type === "signature_delta" && delta.signature) { | |
| const events: LLMEvent[] = [] | |
| return [ | |
| { | |
| ...state, | |
| lifecycle: Lifecycle.reasoningEnd( | |
| state.lifecycle, | |
| events, | |
| `reasoning-${event.index ?? 0}`, | |
| anthropicMetadata({ signature: delta.signature }), | |
| ), | |
| }, | |
| events, | |
| ] satisfies StepResult | |
| } | |
| if (delta?.type === "input_json_delta" && event.index !== undefined) { | |
| if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult | |
| const result = ToolStream.appendExisting( | |
| ADAPTER, | |
| state.tools, | |
| event.index, | |
| delta.partial_json, | |
| "Anthropic Messages tool argument delta is missing its tool call", | |
| ) | |
| if (ToolStream.isError(result)) return yield* result | |
| const events: LLMEvent[] = [] | |
| const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle | |
| events.push(...result.events) | |
| return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult | |
| } | |
| return [state, NO_EVENTS] satisfies StepResult | |
| }) | |
| const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(function* ( | |
| state: ParserState, | |
| event: AnthropicEvent, | |
| ) { | |
| if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult | |
| const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index) | |
| const events: LLMEvent[] = [] | |
| const resultEvents = result.events ?? [] | |
| const lifecycle = resultEvents.length | |
| ? Lifecycle.stepStart(state.lifecycle, events) | |
| : Lifecycle.reasoningEnd( | |
| Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`), | |
| events, | |
| `reasoning-${event.index}`, | |
| ) | |
| events.push(...resultEvents) | |
| return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult | |
| }) | |
| const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => { | |
| const usage = mergeUsage(state.usage, mapUsage(event.usage)) | |
| const events: LLMEvent[] = [] | |
| const lifecycle = Lifecycle.finish(state.lifecycle, events, { | |
| reason: mapFinishReason(event.delta?.stop_reason), | |
| usage, | |
| providerMetadata: event.delta?.stop_sequence | |
| ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) | |
| : undefined, | |
| }) | |
| return [{ ...state, lifecycle, usage }, events] | |
| } | |
| // Prefix `error.type` so overloads, rate limits, and quota errors are visible | |
| // even when the provider message is generic or empty. | |
| const providerErrorMessage = (event: AnthropicEvent): string => { | |
| const type = event.error?.type | |
| const message = event.error?.message | |
| if (type && message) return `${type}: ${message}` | |
| return message || type || "Anthropic Messages stream error" | |
| } | |
| const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ | |
| state, | |
| [ | |
| LLMEvent.providerError({ | |
| message: providerErrorMessage(event), | |
| classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined, | |
| }), | |
| ], | |
| ] | |
| const step = (state: ParserState, event: AnthropicEvent) => { | |
| if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event)) | |
| if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event)) | |
| if (event.type === "content_block_delta") return onContentBlockDelta(state, event) | |
| if (event.type === "content_block_stop") return onContentBlockStop(state, event) | |
| if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event)) | |
| if (event.type === "error") return Effect.succeed(onError(state, event)) | |
| return Effect.succeed<StepResult>([state, NO_EVENTS]) | |
| } | |
| // ============================================================================= | |
| // Protocol And Anthropic Route | |
| // ============================================================================= | |
| /** | |
| * The Anthropic Messages protocol — request body construction, body schema, | |
| * and the streaming-event state machine. Used by native Anthropic Cloud and | |
| * (once registered) Vertex Anthropic / Bedrock-hosted Anthropic passthrough. | |
| */ | |
| export const protocol = Protocol.make({ | |
| id: ADAPTER, | |
| body: { | |
| schema: AnthropicMessagesBody, | |
| from: fromRequest, | |
| }, | |
| stream: { | |
| event: Protocol.jsonEvent(AnthropicEvent), | |
| initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }), | |
| step, | |
| }, | |
| }) | |
| export const route = Route.make({ | |
| id: ADAPTER, | |
| provider: "anthropic", | |
| protocol, | |
| endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), | |
| auth: Auth.none, | |
| framing: Framing.sse, | |
| headers: () => ({ "anthropic-version": "2023-06-01" }), | |
| }) | |
| export * as AnthropicMessages from "./anthropic-messages" | |
Xet Storage Details
- Size:
- 33.2 kB
- Xet hash:
- bf77c133b63600de6af8accd2494eb77d10a917de2f24b453c13c9cb1551a514
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.