diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index 0d8e5a784a..3165005aa1 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -182,6 +182,34 @@ describe("AwsBedrockHandler", () => { }, ) + it.each([ + ["anthropic.claude-sonnet-4-5-20250929-v1:0", false], + ["minimax.minimax-m2", true], + ] as const)("replays stored thinking to %s only when the model preserves reasoning", async (apiModelId, kept) => { + const provider = new AwsBedrockHandler({ apiModelId, awsRegion: "us-east-1" }) + provider["client"].send = vi.fn().mockResolvedValue({ stream: asyncStreamFrom([]) }) + // A MiniMax-signed thinking block survives a mid-task switch to Bedrock. + const history = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Signed by MiniMax.", signature: "minimax-signature" }, + { type: "text", text: "Done." }, + { type: "tool_use", id: "t1", name: "read_file", input: {} }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "ok" }] }, + ] as Anthropic.Messages.MessageParam[] + await collectStream(provider.createMessage("system", history)) + const [{ messages }] = mockConverseStreamCommand.mock.lastCall! + expect(messages?.[1].content).toStrictEqual([ + ...(kept ? [{ reasoningContent: { reasoningText: { text: "Signed by MiniMax." } } }] : []), + { text: "Done." }, + { toolUse: { toolUseId: "t1", name: "read_file", input: {} } }, + ]) + }) + describe("getModel", () => { it("should return the correct model info for a standard model", () => { const modelInfo = handler.getModel() diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 5d87ef3de8..37967d0b4f 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -436,6 +436,85 @@ describe("MiniMaxHandler", () => { expect(firstChunk.value).toEqual({ type: "reasoning", text: thinkingContent }) }) + it("captures thinking signatures for the next tool-loop request", async () => { + mockCreate.mockResolvedValueOnce( + asyncStreamFrom([ + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Inspect the file." }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "signed-reasoning" }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage("system prompt", [])) + + expect(chunks).toEqual([{ type: "reasoning", text: "Inspect the file." }]) + expect(handler.getThoughtSignature()).toBe("signed-reasoning") + }) + + it("clears stale thinking signatures before the next request", async () => { + mockCreate + .mockResolvedValueOnce( + asyncStreamFrom([ + { + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "signed-reasoning" }, + }, + ]), + ) + .mockResolvedValueOnce( + asyncStreamFrom([ + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Continue without a signature." }, + }, + ]), + ) + + await collectStream(handler.createMessage("system prompt", [])) + expect(handler.getThoughtSignature()).toBe("signed-reasoning") + + await collectStream(handler.createMessage("system prompt", [])) + expect(handler.getThoughtSignature()).toBeUndefined() + }) + + it("filters legacy reasoning blocks while preserving signed thinking blocks", async () => { + mockCreate.mockResolvedValueOnce(asyncStreamFrom([])) + // The Anthropic SDK does not model Zoo Code's legacy internal reasoning block, + // which can still be present in persisted conversation history. + const messages = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "legacy unsigned reasoning", summary: [] }, + { type: "thinking", thinking: "signed reasoning", signature: "signature" }, + { type: "text", text: "I will inspect the file." }, + ], + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + await collectStream(handler.createMessage("system prompt", messages)) + + const request = mockCreate.mock.calls[0][0] as Anthropic.Messages.MessageCreateParams + expect(request.messages).toEqual([ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "signed reasoning", signature: "signature" }, + { type: "text", text: "I will inspect the file." }, + ], + }, + ]) + }) + it("should handle tool calls in stream", async () => { mockCreate.mockResolvedValueOnce( asyncStreamFrom([ diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index e09cc0b17b..71b256e776 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -984,7 +984,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH conversationId?: string, // Optional conversation ID to track cache points across messages ): { system: SystemContentBlock[]; messages: Message[] } { // First convert messages using shared converter for proper image handling - const convertedMessages = sharedConverter(anthropicMessages as Anthropic.Messages.MessageParam[]) + const convertedMessages = sharedConverter(anthropicMessages as Anthropic.Messages.MessageParam[], { + preserveReasoning: modelInfo?.preserveReasoning === true, + }) // If prompt caching is disabled, return the converted messages directly if (!usePromptCache) { diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 52c42a4138..44516a4dca 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -11,6 +11,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { mergeEnvironmentDetailsForMiniMax } from "../transform/minimax-format" +import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" import { BaseProvider } from "./base-provider" import { bridgeExternalAbortSignal, hasUsableTimeout, toSdkTimeoutMs } from "./utils/abort-signal" @@ -55,6 +56,7 @@ function convertOpenAIToolChoice( export class MiniMaxHandler extends BaseProvider implements SingleCompletionHandler { private options: ApiHandlerOptions private client: Anthropic + private lastThoughtSignature?: string constructor(options: ApiHandlerOptions) { super() @@ -86,6 +88,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand ): ApiStream { const cacheControl: CacheControlEphemeral = { type: "ephemeral" } const { id: modelId, info, maxTokens, temperature } = this.getModel() + this.lastThoughtSignature = undefined // Bridge the external abort signal from request metadata into a per-request // controller so the SDK call is cancelled when the owning request is aborted @@ -103,7 +106,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand // into the tool_result content. This preserves reasoning continuity for // thinking models by preventing user messages from interrupting the // reasoning context after tool use (similar to r1-format's mergeToolResultText). - const processedMessages = mergeEnvironmentDetailsForMiniMax(messages) + const processedMessages = filterNonAnthropicBlocks(mergeEnvironmentDetailsForMiniMax(messages)) // Build the system blocks array const systemBlocks: Anthropic.Messages.TextBlockParam[] = [ @@ -213,6 +216,9 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand case "thinking_delta": yield { type: "reasoning", text: chunk.delta.thinking } break + case "signature_delta": + this.lastThoughtSignature = chunk.delta.signature + break case "text_delta": yield { type: "text", text: chunk.delta.text } break @@ -255,6 +261,10 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand } } + getThoughtSignature(): string | undefined { + return this.lastThoughtSignature + } + /** * Add cache control to the last two user messages for prompt caching */ diff --git a/src/api/transform/__tests__/bedrock-converse-format.spec.ts b/src/api/transform/__tests__/bedrock-converse-format.spec.ts index 27319c6562..fb980fa52b 100644 --- a/src/api/transform/__tests__/bedrock-converse-format.spec.ts +++ b/src/api/transform/__tests__/bedrock-converse-format.spec.ts @@ -1,6 +1,6 @@ // npx vitest run src/api/transform/__tests__/bedrock-converse-format.spec.ts -import { convertToBedrockConverseMessages } from "../bedrock-converse-format" +import { convertToBedrockConverseMessages, REASONING_ONLY_PLACEHOLDER } from "../bedrock-converse-format" import { Anthropic } from "@anthropic-ai/sdk" import { ContentBlock, ToolResultContentBlock } from "@aws-sdk/client-bedrock-runtime" import { OPENAI_CALL_ID_MAX_LENGTH } from "../../../utils/tool-id" @@ -26,6 +26,121 @@ describe("convertToBedrockConverseMessages", () => { ]) }) + it("converts internal reasoning blocks to Bedrock reasoning content", () => { + // The Anthropic SDK does not model Zoo Code's internal reasoning block, + // though this converter receives it from persisted conversation history. + const messages = [ + { + role: "assistant", + content: [{ type: "reasoning", text: "I should inspect the file first.", summary: [] }], + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + expect(convertToBedrockConverseMessages(messages, { preserveReasoning: true })).toEqual([ + { + role: "assistant", + content: [ + { + reasoningContent: { + reasoningText: { text: "I should inspect the file first." }, + }, + }, + ], + }, + ]) + }) + + it("replays reasoning into a tool_result continuation but drops it before a new user message", () => { + const reasoning = { type: "reasoning", text: "Plan.", summary: [] } + const messages = [ + { role: "assistant", content: [reasoning, { type: "tool_use", id: "t1", name: "read", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "ok" }] }, + { role: "assistant", content: [reasoning, { type: "text", text: "Done." }] }, + { role: "user", content: [{ type: "text", text: "next" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const result = convertToBedrockConverseMessages(messages, { preserveReasoning: true }) + + expect(result[0].content?.[0]).toEqual({ reasoningContent: { reasoningText: { text: "Plan." } } }) + expect(result[2].content).toEqual([{ text: "Done." }]) + }) + + it("drops reasoning and thinking blocks for models that do not preserve reasoning", () => { + // Signed thinking bypasses Task's reasoning filter, so the converter must not send it to e.g. Claude. + const messages = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Signed elsewhere.", signature: "minimax-signature" }, + { type: "text", text: "Reading the file." }, + ], + }, + { + role: "assistant", + content: [{ type: "reasoning", text: "Reasoning only.", summary: [] }], + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + expect(convertToBedrockConverseMessages(messages)).toStrictEqual([ + { role: "assistant", content: [{ text: "Reading the file." }] }, + // Bedrock rejects blank text blocks, so a reasoning-only turn keeps a non-blank placeholder. + { role: "assistant", content: [{ text: REASONING_ONLY_PLACEHOLDER }] }, + ]) + expect(REASONING_ONLY_PLACEHOLDER.trim()).not.toBe("") + }) + + it("drops signatures issued by another provider when converting thinking blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "I should inspect the file first.", + signature: "signed-reasoning", + }, + ], + }, + ] + + // Bedrock never records signatures, so a stored one would fail Bedrock's verification. + expect(convertToBedrockConverseMessages(messages, { preserveReasoning: true })).toStrictEqual([ + { + role: "assistant", + content: [ + { + reasoningContent: { + reasoningText: { text: "I should inspect the file first." }, + }, + }, + ], + }, + ]) + }) + + it("converts unsigned thinking blocks without adding a signature", () => { + // Persisted provider output can omit a signature even though the Anthropic SDK requires one. + const messages = [ + { + role: "assistant", + content: [{ type: "thinking", thinking: "I should inspect the file first." }], + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + expect(convertToBedrockConverseMessages(messages, { preserveReasoning: true })).toStrictEqual([ + { + role: "assistant", + content: [ + { + reasoningContent: { + reasoningText: { text: "I should inspect the file first." }, + }, + }, + ], + }, + ]) + }) + it("converts messages with images correctly", () => { const messages: Anthropic.Messages.MessageParam[] = [ { diff --git a/src/api/transform/bedrock-converse-format.ts b/src/api/transform/bedrock-converse-format.ts index 2a49d72bce..1094c44e71 100644 --- a/src/api/transform/bedrock-converse-format.ts +++ b/src/api/transform/bedrock-converse-format.ts @@ -2,9 +2,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import { ConversationRole, Message, ContentBlock } from "@aws-sdk/client-bedrock-runtime" import { sanitizeOpenAiCallId } from "../../utils/tool-id" +/** Stand-in text for a turn whose only content was filtered reasoning (Bedrock rejects blank text). */ +export const REASONING_ONLY_PLACEHOLDER = "[Reasoning omitted]" + interface BedrockMessageContent { - type: "text" | "image" | "video" | "tool_use" | "tool_result" + type: "text" | "image" | "video" | "tool_use" | "tool_result" | "reasoning" | "thinking" text?: string + thinking?: string source?: { type: "base64" data: string | Uint8Array // string for Anthropic, Uint8Array for Bedrock @@ -26,9 +30,15 @@ interface BedrockMessageContent { /** * Convert Anthropic messages to Bedrock Converse format * @param anthropicMessages Messages in Anthropic format + * @param options.preserveReasoning Replay reasoning/thinking blocks as `reasoningContent` while a tool loop + * continues; reasoning followed by a new user message is dropped. + * Otherwise they are dropped, matching Task's stripping for models without `preserveReasoning`. */ -export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { - return anthropicMessages.map((anthropicMessage) => { +export function convertToBedrockConverseMessages( + anthropicMessages: Anthropic.Messages.MessageParam[], + { preserveReasoning = false }: { preserveReasoning?: boolean } = {}, +): Message[] { + return anthropicMessages.map((anthropicMessage, index) => { // Map Anthropic roles to Bedrock roles const role: ConversationRole = anthropicMessage.role === "assistant" ? "assistant" : "user" @@ -43,8 +53,25 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me } } + // Signed thinking blocks (e.g. from MiniMax) bypass Task's reasoning filter, so they can reach + // any Bedrock model after a provider switch. Unsigned reasoning is only safe for models that + // opt in; Claude would receive thinking it cannot verify. + // Reasoning continuity only spans a tool loop: a following new user message ends it. + const next = anthropicMessages[index + 1] + const endsAtNewUserMessage = + next?.role === "user" && + (typeof next.content === "string" || + !next.content.some((block) => (block as { type: string }).type === "tool_result")) + const blocks = + preserveReasoning && !endsAtNewUserMessage + ? anthropicMessage.content + : anthropicMessage.content.filter((block) => { + const type = (block as { type: string }).type + return type !== "reasoning" && type !== "thinking" + }) + // Process complex content types - const content = anthropicMessage.content.map((block) => { + const content = blocks.map((block) => { const messageBlock = block as BedrockMessageContent & { id?: string tool_use_id?: string @@ -58,6 +85,25 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me } as ContentBlock } + if (messageBlock.type === "reasoning" && typeof messageBlock.text === "string") { + return { + reasoningContent: { + reasoningText: { text: messageBlock.text }, + }, + } as ContentBlock + } + + if (messageBlock.type === "thinking" && typeof messageBlock.thinking === "string") { + // Bedrock does not capture its own signatures, so any stored signature was issued by + // another provider (e.g. MiniMax) and would fail Bedrock's verification. Unsigned + // reasoning is accepted (`signature` is optional), so replay the text only. + return { + reasoningContent: { + reasoningText: { text: messageBlock.thinking }, + }, + } as ContentBlock + } + if (messageBlock.type === "image" && messageBlock.source) { // Convert base64 string to byte array if needed let byteArray: Uint8Array @@ -203,7 +249,12 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me return { role, - content, + // Bedrock rejects an empty content array and blank text blocks, so a turn that held only + // reasoning keeps its place in the conversation with a short non-blank placeholder. + content: + content.length === 0 && anthropicMessage.content.length > 0 + ? ([{ text: REASONING_ONLY_PLACEHOLDER }] as ContentBlock[]) + : content, } }) }