Skip to content
Merged
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
13 changes: 2 additions & 11 deletions packages/ai/src/protocols/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,7 @@ const requireThinkingSignature = (request: LLMRequest) => {
)
return false
if (provider.includes("xiaomi") || model.includes("mimo") || baseURL.includes("xiaomimimo.com")) return false
if (model.startsWith("qwen") || model.startsWith("minimax-")) return false
return true
}

Expand Down Expand Up @@ -971,19 +972,9 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
continue
}
if (!signature || signature.trim().length === 0) {
if (part.text.trim().length === 0) continue
if (!requireThinkingSignature(request)) {
if (part.text.trim().length > 0 && !requireThinkingSignature(request)) {
content.push({ type: "thinking", thinking: part.text, signature: "" })
continue
}
// Without a signature this cannot be a valid thinking block per
// the SDK ThinkingBlockParam:3217 — demote to text so the
// conversation remains sendable.
content.push({
type: "text",
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
})
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
Expand Down
7 changes: 1 addition & 6 deletions packages/ai/src/protocols/bedrock-converse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,12 +427,7 @@ const lowerMessages = Effect.fnUntraced(function* (
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
}
if (signature === undefined || signature.trim().length === 0) {
// Interrupted streams and model switches can leave unsigned reasoning.
// Preserve readable history as text rather than replay invalid reasoningContent.
if (part.text.trim().length > 0) content.push(...textWithCache(breakpoints, part.text, part.cache))
continue
}
if (signature === undefined || signature.trim().length === 0) continue
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
continue
}
Expand Down
18 changes: 2 additions & 16 deletions packages/ai/src/protocols/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,12 @@ import { Lifecycle } from "./utils/lifecycle.js"

const ADAPTER = "gemini"
const CONTINUATION = "CONTINUATION"
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
// Google documents this sentinel for replaying function calls after their original signature was lost.
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
// Gemini 2.5 rejects a budget under the model's minimum: 512 on Flash-Lite, the highest, and 128 on Pro.
const MIN_THINKING_BUDGET = 512
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"

// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
// retaining complete model responses, but OpenCode reconstructs durable history and may encounter an unsigned call
// from an older or external session. Model IDs are open-ended, so unknown Gemini aliases inherit current behavior.
const requiresThoughtSignatureFallback = (modelID: string) => {
if (!/(^|\/)gemini-/i.test(modelID)) return false
if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false
if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
}

// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
// so their tool-result attachments lower as a separate user turn instead.
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
Expand Down Expand Up @@ -390,11 +380,7 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
const signature = lowered.thoughtSignature
parts.push({
...lowered,
thoughtSignature:
signature ??
(requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall
? SKIP_THOUGHT_SIGNATURE_VALIDATOR
: undefined),
thoughtSignature: signature ?? (!hasSignedToolCall ? SKIP_THOUGHT_SIGNATURE_VALIDATOR : undefined),
})
if (signature !== undefined) hasSignedToolCall = true
continue
Expand Down
10 changes: 8 additions & 2 deletions packages/ai/src/protocols/google-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
import { ToolStream } from "./utils/tool-stream.js"

const ADAPTER = "google-interactions"
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"

// =============================================================================
Expand Down Expand Up @@ -259,6 +260,7 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
steps[steps.length - 1] = { type, content: [...last.content, content] }
else steps.push({ type, content: [content] })
}
let hasSignedStep = false
for (const part of message.content) {
if (message.role === "tool") {
if (part.type !== "tool-result")
Expand All @@ -281,21 +283,25 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
continue
}
if (message.role === "assistant" && part.type === "reasoning") {
const thoughtSignature = signature(part.providerMetadata, key)
steps.push({
type: "thought",
signature: signature(part.providerMetadata, key),
signature: thoughtSignature,
summary: part.text ? [{ type: "text", text: part.text }] : undefined,
})
if (thoughtSignature !== undefined) hasSignedStep = true
continue
}
if (message.role === "assistant" && part.type === "tool-call") {
const toolSignature = signature(part.providerMetadata, key)
steps.push({
type: "function_call",
id: part.id,
name: part.name,
arguments: part.input,
signature: signature(part.providerMetadata, key),
signature: toolSignature ?? (!hasSignedStep ? SKIP_THOUGHT_SIGNATURE_VALIDATOR : undefined),
})
if (toolSignature !== undefined) hasSignedStep = true
continue
}
return yield* ProviderShared.unsupportedContent(
Expand Down
14 changes: 6 additions & 8 deletions packages/ai/src/protocols/mistral-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,6 @@ const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string):
const lowerAssistant = Effect.fnUntraced(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
prefix: boolean,
) {
const structured = message.content.some(
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
Expand All @@ -271,8 +270,6 @@ const lowerAssistant = Effect.fnUntraced(function* (
if (part.type === "reasoning") {
const native = part.providerMetadata?.mistral?.thinking
if (structured && isMistralThinkingContent(native)) content.push(native)
else if (structured) content.push({ type: "text", text: part.text })
else text.push(part.text)
continue
}
if (part.type === "tool-call") {
Expand All @@ -285,7 +282,6 @@ const lowerAssistant = Effect.fnUntraced(function* (
role: "assistant" as const,
content: structured ? content : text.join(""),
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
...(prefix ? { prefix: true as const } : {}),
}
})

Expand Down Expand Up @@ -358,15 +354,17 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
const hasNativeThinking = message.content.some(
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
)
const text = message.content
.flatMap((part) => (part.type === "text" || part.type === "reasoning" ? [part.text] : []))
.join("")
const text = message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
if (!hasToolCalls && !hasNativeThinking && text.trim() === "") continue
messages.push(yield* lowerAssistant(message, normalizeID, !hasToolCalls && message === request.messages.at(-1)))
messages.push(yield* lowerAssistant(message, normalizeID))
continue
}
messages.push(...(yield* lowerToolResults(message, normalizeID)))
}
const trailing = messages.at(-1)
if (trailing?.role === "assistant" && !trailing.tool_calls) {
messages[messages.length - 1] = { ...trailing, prefix: true }
}
return messages
})

Expand Down
1 change: 0 additions & 1 deletion packages/ai/src/providers/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ export const configure = (input: Config = {}) => {
const messages = (modelID: string | ModelID) =>
configuredMessages.model<MessagesOptionsInput>({
id: modelID,
compatibility: { requireSignature: false },
})
const media = MediaRoute.deployment(input, options.auth)
const image = (modelID: string | ModelID) => MetaImages.model({ ...media, id: modelID })
Expand Down
5 changes: 4 additions & 1 deletion packages/ai/src/providers/minimax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,10 @@ export const configure = (input: Config = {}) => {
auth,
}
const messages = (modelID: string | ModelID) =>
messagesRoute.with(defaults).model<MessagesOptionsInput>({ id: modelID })
messagesRoute.with(defaults).model<MessagesOptionsInput>({
id: modelID,
compatibility: { requireSignature: false },
})
const chat = (modelID: string | ModelID) =>
chatRoute.with(defaults).model<ChatOptionsInput>({
id: modelID,
Expand Down
34 changes: 15 additions & 19 deletions packages/ai/test/provider/anthropic-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,13 +1035,11 @@ describe("Anthropic Messages route", () => {
}),
)

it.effect("demotes unsigned reasoning when signatures are required", () =>
it.effect("drops unsigned reasoning when signatures are required", () =>
Effect.gen(function* () {
const prepared = yield* compileUnsignedReasoning(model)

expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
expect(prepared.body.messages).toEqual([])
}),
)

Expand All @@ -1061,19 +1059,19 @@ describe("Anthropic Messages route", () => {
.model({ id: "kimi-k2.6" })
const codingPrepared = yield* compileUnsignedReasoning(coding.model({ id: "k3" }))
const moonshotPrepared = yield* compileUnsignedReasoning(moonshot)
const qwenPrepared = yield* compileUnsignedReasoning(coding.with({ provider: "opencode" }).model({ id: "qwen3.8-flash" }))
const minimaxPrepared = yield* compileUnsignedReasoning(
coding.with({ provider: "opencode-go" }).model({ id: "minimax-m3" }),
)

expect(codingPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
expect(moonshotPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
for (const prepared of [codingPrepared, moonshotPrepared, qwenPrepared, minimaxPrepared]) {
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
}
}),
)

Expand All @@ -1088,9 +1086,7 @@ describe("Anthropic Messages route", () => {
.model({ id: "k3", compatibility: { requireSignature: true } })
const prepared = yield* compileUnsignedReasoning(compatible)

expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
expect(prepared.body.messages).toEqual([])
}),
)

Expand Down
2 changes: 1 addition & 1 deletion packages/ai/test/provider/bedrock-converse-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe("Bedrock Converse cache policy", () => {
{ role: "user", content: [{ text: "Question" }, { text: "<system-update>\nUpdate\n</system-update>" }] },
{
role: "assistant",
content: [{ text: "Answer" }, { text: "Unsigned reasoning" }, { toolUse: { name: "lookup" } }],
content: [{ text: "Answer" }, { toolUse: { name: "lookup" } }],
},
{ role: "user", content: [{ toolResult: { content: [{ json: "Result" }] } }] },
],
Expand Down
18 changes: 11 additions & 7 deletions packages/ai/test/provider/bedrock-converse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1197,7 +1197,7 @@ describe("Bedrock Converse route", () => {

for (const signature of [undefined, "", " "]) {
for (const cache of ["none", "auto"] as const) {
it.effect(`demotes unsigned reasoning to text (${JSON.stringify(signature)}, cache: ${cache})`, () =>
it.effect(`drops unsigned reasoning (${JSON.stringify(signature)}, cache: ${cache})`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
ai.request({
Expand All @@ -1217,10 +1217,15 @@ describe("Bedrock Converse route", () => {
cache,
}),
)
expect(prepared.body.messages[1]).toEqual({
role: "assistant",
content: [{ text: "Partial thought" }, { cachePoint: { type: "default" } }],
})
expect(prepared.body.messages).toEqual([
{
role: "user",
content:
cache === "auto"
? [{ text: "Think" }, { text: "Continue" }, { cachePoint: { type: "default" } }]
: [{ text: "Think" }, { text: "Continue" }],
},
])
}),
)
}
Expand Down Expand Up @@ -1252,7 +1257,7 @@ describe("Bedrock Converse route", () => {
}),
)

it.effect("demotes foreign reasoning while preserving signed, redacted, text, and tool blocks", () =>
it.effect("drops foreign reasoning while preserving signed, redacted, text, and tool blocks", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
Expand Down Expand Up @@ -1281,7 +1286,6 @@ describe("Bedrock Converse route", () => {
{
role: "assistant",
content: [
{ text: "Foreign thought" },
{ reasoningContent: { reasoningText: { text: "Signed thought", signature: "sig_1" } } },
{ reasoningContent: { reasoningText: { text: "", signature: "sig_2" } } },
{ reasoningContent: { redactedContent: "cmVkYWN0ZWQ=" } },
Expand Down
Loading
Loading