diff --git a/.changeset/fix-google-thought-signature.md b/.changeset/fix-google-thought-signature.md new file mode 100644 index 00000000..15be3f53 --- /dev/null +++ b/.changeset/fix-google-thought-signature.md @@ -0,0 +1,5 @@ +--- +"perstack": patch +--- + +fix: support Google/Vertex thought_signature in multi-turn conversations with Gemini 3 models diff --git a/packages/core/src/schemas/message-part.ts b/packages/core/src/schemas/message-part.ts index 4a5e52ad..2dffd2d9 100644 --- a/packages/core/src/schemas/message-part.ts +++ b/packages/core/src/schemas/message-part.ts @@ -144,14 +144,17 @@ export interface ThinkingPart extends BasePart { type: "thinkingPart" /** The thinking content */ thinking: string - /** Signature for redacted thinking blocks (Anthropic) */ + /** Signature for thinking blocks (required by Anthropic and Google) */ signature?: string + /** Provider namespace for the signature (defaults to "anthropic" for backward compatibility) */ + signatureProvider?: "anthropic" | "google" | "vertex" } export const thinkingPartSchema = basePartSchema.extend({ type: z.literal("thinkingPart"), thinking: z.string(), signature: z.string().optional(), + signatureProvider: z.enum(["anthropic", "google", "vertex"]).optional(), }) thinkingPartSchema satisfies z.ZodType diff --git a/packages/runtime/src/helpers/thinking.test.ts b/packages/runtime/src/helpers/thinking.test.ts index 54b69a2b..4288e202 100644 --- a/packages/runtime/src/helpers/thinking.test.ts +++ b/packages/runtime/src/helpers/thinking.test.ts @@ -20,8 +20,18 @@ describe("@perstack/runtime: thinking", () => { const result = extractThinkingParts(reasoning) expect(result).toEqual([ - { type: "thinkingPart", thinking: "First thought", signature: undefined }, - { type: "thinkingPart", thinking: "Second thought", signature: undefined }, + { + type: "thinkingPart", + thinking: "First thought", + signature: undefined, + signatureProvider: undefined, + }, + { + type: "thinkingPart", + thinking: "Second thought", + signature: undefined, + signatureProvider: undefined, + }, ]) }) @@ -45,6 +55,57 @@ describe("@perstack/runtime: thinking", () => { type: "thinkingPart", thinking: "Thinking with signature", signature: "test-signature-123", + signatureProvider: "anthropic", + }, + ]) + }) + + it("extracts thoughtSignature from Google providerMetadata", () => { + const reasoning: ReasoningPart[] = [ + { + type: "reasoning", + text: "Google thinking", + providerMetadata: { + google: { + thoughtSignature: "google-sig-456", + }, + }, + }, + ] + + const result = extractThinkingParts(reasoning) + + expect(result).toEqual([ + { + type: "thinkingPart", + thinking: "Google thinking", + signature: "google-sig-456", + signatureProvider: "google", + }, + ]) + }) + + it("extracts thoughtSignature from Vertex providerMetadata", () => { + const reasoning: ReasoningPart[] = [ + { + type: "reasoning", + text: "Vertex thinking", + providerMetadata: { + vertex: { + thoughtSignature: "vertex-sig-789", + }, + }, + }, + ] + + const result = extractThinkingParts(reasoning) + + expect(result).toEqual([ + { + type: "thinkingPart", + thinking: "Vertex thinking", + signature: "vertex-sig-789", + signatureProvider: "vertex", }, ]) }) @@ -65,6 +126,7 @@ describe("@perstack/runtime: thinking", () => { expect(result).toHaveLength(3) expect(result[0]?.signature).toBeUndefined() expect(result[1]?.signature).toBe("sig-1") + expect(result[1]?.signatureProvider).toBe("anthropic") expect(result[2]?.signature).toBeUndefined() }) }) diff --git a/packages/runtime/src/helpers/thinking.ts b/packages/runtime/src/helpers/thinking.ts index 013400b8..3f394b66 100644 --- a/packages/runtime/src/helpers/thinking.ts +++ b/packages/runtime/src/helpers/thinking.ts @@ -4,8 +4,10 @@ import type { ThinkingPart } from "@perstack/core" * Reasoning part from AI SDK generateText result. * This matches the AI SDK ReasoningPart type. * - * For Anthropic Extended Thinking, signature is in providerMetadata.anthropic.signature - * (not providerOptions - that's for input, providerMetadata is for output) + * Signatures are provider-specific: + * - Anthropic: providerMetadata.anthropic.signature + * - Google: providerMetadata.google.thoughtSignature + * - Vertex: providerMetadata.vertex.thoughtSignature */ export interface ReasoningPart { type: "reasoning" @@ -14,6 +16,12 @@ export interface ReasoningPart { anthropic?: { signature?: string } + google?: { + thoughtSignature?: string + } + vertex?: { + thoughtSignature?: string + } } } @@ -22,19 +30,39 @@ export interface ReasoningPart { * Used to preserve thinking blocks in conversation history for providers * that require them (Anthropic, Google). * - * Note: For Anthropic, signature is required for all thinking blocks - * when including them in conversation history. + * The signature and its provider namespace are preserved so that + * the correct providerOptions can be set when converting back to + * AI SDK messages for multi-turn conversations. */ export function extractThinkingParts( reasoning: ReasoningPart[] | undefined, ): Omit[] { if (!reasoning) return [] - return reasoning.map((r) => ({ - type: "thinkingPart" as const, - thinking: r.text, - // Signature is in providerMetadata for Anthropic (output from API) - signature: r.providerMetadata?.anthropic?.signature, - })) + return reasoning.map((r) => { + const { signature, signatureProvider } = extractSignature(r) + return { + type: "thinkingPart" as const, + thinking: r.text, + signature, + signatureProvider, + } + }) +} + +function extractSignature(r: ReasoningPart): { + signature: string | undefined + signatureProvider: ThinkingPart["signatureProvider"] +} { + if (r.providerMetadata?.anthropic?.signature) { + return { signature: r.providerMetadata.anthropic.signature, signatureProvider: "anthropic" } + } + if (r.providerMetadata?.google?.thoughtSignature) { + return { signature: r.providerMetadata.google.thoughtSignature, signatureProvider: "google" } + } + if (r.providerMetadata?.vertex?.thoughtSignature) { + return { signature: r.providerMetadata.vertex.thoughtSignature, signatureProvider: "vertex" } + } + return { signature: undefined, signatureProvider: undefined } } /** diff --git a/packages/runtime/src/messages/message.ts b/packages/runtime/src/messages/message.ts index 0622bfc6..3877e352 100644 --- a/packages/runtime/src/messages/message.ts +++ b/packages/runtime/src/messages/message.ts @@ -244,10 +244,15 @@ function thinkingPartToCoreThinkingPart(part: ThinkingPart): { text: string providerOptions?: Record> } { + if (!part.signature) { + return { type: "reasoning", text: part.thinking } + } + const provider = part.signatureProvider ?? "anthropic" + const signatureKey = provider === "anthropic" ? "signature" : "thoughtSignature" return { type: "reasoning", text: part.thinking, - providerOptions: part.signature ? { anthropic: { signature: part.signature } } : undefined, + providerOptions: { [provider]: { [signatureKey]: part.signature } }, } } function toolResultPartToCoreToolResultPart(part: ToolResultPart): ToolResultModelPart {