From 4780248e8415eb0a288c607a08ff2eb1aaffc152 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:13:48 -0500 Subject: [PATCH] fix(ai): require thinking signature with provider-aware fallback (#44713) --- .../ai/src/protocols/anthropic-messages.ts | 43 ++++++++++-- packages/ai/src/schema/options.ts | 1 + .../test/provider/anthropic-messages.test.ts | 69 +++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 6c22068278a..b4ab6cbdefa 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -157,7 +157,7 @@ type AnthropicDocumentBlock = Schema.Schema.Type const AnthropicThinkingBlock = Schema.Struct({ type: Schema.tag("thinking"), thinking: Schema.String, - signature: Schema.optional(Schema.String), + signature: Schema.String, cache_control: Schema.optional(AnthropicCacheControl), }) @@ -701,6 +701,26 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart return yield* Effect.forEach(content, lowerToolResultContentItem) }) +const requireThinkingSignature = (request: LLMRequest) => { + if (request.model.compatibility?.requireSignature !== undefined) + return request.model.compatibility.requireSignature + const provider = request.model.provider.toLowerCase() + const model = request.model.id.toLowerCase() + const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase() + if ( + provider === "kimi-for-coding" || + provider === "moonshotai" || + provider === "moonshotai-cn" || + model.startsWith("kimi-") || + baseURL.includes("api.kimi.com/coding") || + baseURL.includes("api.moonshot.ai/anthropic") || + baseURL.includes("api.moonshot.cn/anthropic") + ) + return false + if (provider.includes("xiaomi") || model.includes("mimo") || baseURL.includes("xiaomimimo.com")) return false + return true +} + // Mid-conversation system messages became available with Opus 4.8 and version // 5 of the other supported Claude families. Treat later family versions as // compatible without assuming that every Anthropic Messages model is Claude. @@ -807,15 +827,30 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( continue } if (part.type === "reasoning") { - // Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible - // thinking; only signature-less parts carrying redactedData - // round-trip as opaque redacted_thinking blocks. + // 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 } + if (typeof signature !== "string" || signature.trim().length === 0) { + if (part.text.trim().length === 0) continue + if (!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 }) continue } diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 221b8e744a4..05342d6ebe5 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -159,6 +159,7 @@ export class LanguageModelCompatibility extends Schema.Class + compileRequest( + LLM.request({ + model, + messages: [Message.assistant([{ type: "reasoning", text: "unsigned reasoning" }])], + cache: "none", + }), + ) + const request = LLM.request({ id: "req_1", model, @@ -564,6 +573,66 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("demotes 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" }] }, + ]) + }), + ) + + it.effect("infers empty-signature compatibility across Kimi providers", () => + Effect.gen(function* () { + const coding = AnthropicMessages.route + .with({ + provider: "kimi-for-coding", + endpoint: { baseURL: "https://compatible.test/v1/" }, + auth: Auth.header("x-api-key", "test"), + }) + const moonshot = AnthropicMessages.route + .with({ + provider: "moonshotai", + endpoint: { baseURL: "https://api.moonshot.ai/anthropic" }, + auth: Auth.bearer("test"), + }) + .model({ id: "kimi-k2.6" }) + const codingPrepared = yield* compileUnsignedReasoning(coding.model({ id: "k3" })) + const moonshotPrepared = yield* compileUnsignedReasoning(moonshot) + + 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: "" }], + }, + ]) + }), + ) + + it.effect("lets an explicit signature requirement override inference", () => + Effect.gen(function* () { + const compatible = AnthropicMessages.route + .with({ + provider: "kimi-for-coding", + endpoint: { baseURL: "https://api.kimi.com/coding/v1/" }, + auth: Auth.header("x-api-key", "test"), + }) + .model({ id: "k3", compatibility: { requireSignature: true } }) + const prepared = yield* compileUnsignedReasoning(compatible) + + expect(prepared.body.messages).toEqual([ + { role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] }, + ]) + }), + ) + it.effect("round-trips redacted thinking as redacted_thinking blocks", () => Effect.gen(function* () { const prepared = yield* compileRequest(