Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/api/providers/__tests__/bedrock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
79 changes: 79 additions & 0 deletions src/api/providers/__tests__/minimax.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,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")
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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([
Expand Down
4 changes: 3 additions & 1 deletion src/api/providers/bedrock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,9 @@
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,

Check warning on line 988 in src/api/providers/bedrock.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/bedrock.ts:988: Survived OptionalChaining mutant (replacement: modelInfo.preserveReasoning). See the job summary for the complete list and resolution guidance.
Comment thread
PierrunoYT marked this conversation as resolved.
Resolved
})

// If prompt caching is disabled, return the converted messages directly
if (!usePromptCache) {
Expand Down
12 changes: 11 additions & 1 deletion src/api/providers/minimax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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[] = [
Expand Down Expand Up @@ -224,6 +227,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
Expand Down Expand Up @@ -267,6 +273,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
*/
Expand Down
117 changes: 116 additions & 1 deletion src/api/transform/__tests__/bedrock-converse-format.spec.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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[] = [
{
Expand Down
Loading
Loading