diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 4f5ad902a0..d8fe2f4468 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -75,6 +75,15 @@ const AnthropicThinkingBlock = Schema.Struct({ cache_control: Schema.optional(AnthropicCacheControl), }) +// Safety-filtered thinking arrives as an opaque encrypted `data` payload with +// no visible text. It must round-trip verbatim so multi-turn thinking + tool +// use conversations keep their reasoning continuity. +const AnthropicRedactedThinkingBlock = Schema.Struct({ + type: Schema.tag("redacted_thinking"), + data: Schema.String, + cache_control: Schema.optional(AnthropicCacheControl), +}) + const AnthropicToolUseBlock = Schema.Struct({ type: Schema.tag("tool_use"), id: Schema.String, @@ -136,6 +145,7 @@ type AnthropicUserBlock = Schema.Schema.Type const AnthropicAssistantBlock = Schema.Union([ AnthropicTextBlock, AnthropicThinkingBlock, + AnthropicRedactedThinkingBlock, AnthropicToolUseBlock, AnthropicServerToolUseBlock, AnthropicServerToolResultBlock, @@ -214,6 +224,9 @@ const AnthropicStreamBlock = Schema.Struct({ text: Schema.optional(Schema.String), thinking: Schema.optional(Schema.String), signature: Schema.optional(Schema.String), + // redacted_thinking blocks arrive whole in content_block_start with the + // encrypted payload in `data`; there is no streaming delta sequence. + data: 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 @@ -287,6 +300,12 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | return typeof anthropic.signature === "string" ? anthropic.signature : undefined } +const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => { + const anthropic = metadata?.anthropic + if (!ProviderShared.isRecord(anthropic)) return undefined + return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined +} + const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({ name: tool.name, description: tool.description, @@ -472,11 +491,16 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( continue } if (part.type === "reasoning") { - content.push({ - type: "thinking", - thinking: part.text, - signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata), - }) + // Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible + // thinking; only signature-less parts carrying redactedData + // round-trip as opaque redacted_thinking blocks. + const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata) + const redactedData = redactedDataFromMetadata(part.providerMetadata) + if (signature === undefined && redactedData !== undefined) { + content.push({ type: "redacted_thinking", data: redactedData }) + continue + } + content.push({ type: "thinking", thinking: part.text, signature }) continue } if (part.type === "tool-call") { @@ -747,6 +771,25 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes ] } + // Redacted thinking surfaces as an empty reasoning part carrying the opaque + // payload as `redactedData` metadata (same model as Vercel's + // @ai-sdk/anthropic). The existing content_block_stop closes the part. + if (block.type === "redacted_thinking" && block.data) { + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.reasoningStart( + state.lifecycle, + events, + `reasoning-${event.index ?? 0}`, + anthropicMetadata({ redactedData: block.data }), + ), + }, + events, + ] + } + const result = serverToolResultEvent(block) if (!result) return [state, NO_EVENTS] const events: LLMEvent[] = [] diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index a6fcd628a2..021738d75f 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -409,6 +409,34 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("round-trips redacted thinking as redacted_thinking blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + { type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } }, + { type: "reasoning", text: "visible", providerMetadata: { anthropic: { signature: "sig_1" } } }, + ]), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "assistant", + content: [ + { type: "redacted_thinking", data: "opaque_1" }, + { type: "thinking", thinking: "visible", signature: "sig_1" }, + ], + }, + ], + }) + }), + ) + it.effect("parses text, reasoning, and usage stream fixtures", () => Effect.gen(function* () { const body = sseEvents( @@ -454,6 +482,105 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "redacted_thinking", data: "opaque_1" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } }, + { type: "message_stop" }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events.find((event) => event.type === "reasoning-start")).toMatchObject({ + providerMetadata: { anthropic: { redactedData: "opaque_1" } }, + }) + expect(response.message.content).toEqual([ + { type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } }, + { type: "text", text: "Hello" }, + ]) + }), + ) + + it.effect("round-trips streamed redacted thinking with tool use into a continuation request", () => + Effect.gen(function* () { + // Anthropic types `redacted_thinking.data` as an opaque string. Its + // contents are provider-owned and must be replayed without inspection. + const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc=" + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "redacted_thinking", data: redactedData }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "call_1", name: "lookup" }, + }, + { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' }, + }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ), + ), + ), + ) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.user("Say hello."), + response.message, + Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }), + ], + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + cache: "none", + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "user", content: [{ type: "text", text: "Say hello." }] }, + { + role: "assistant", + content: [ + { type: "redacted_thinking", data: redactedData }, + { type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_1", + content: "sunny", + is_error: undefined, + cache_control: undefined, + }, + ], + }, + ]) + }), + ) + it.effect("maps context-window truncation to length", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe(