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
5 changes: 5 additions & 0 deletions .changeset/fix-google-thought-signature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"perstack": patch
---

fix: support Google/Vertex thought_signature in multi-turn conversations with Gemini 3 models
5 changes: 4 additions & 1 deletion packages/core/src/schemas/message-part.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThinkingPart>

Expand Down
66 changes: 64 additions & 2 deletions packages/runtime/src/helpers/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
])
})

Expand All @@ -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",
},
])
})
Expand All @@ -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()
})
})
Expand Down
48 changes: 38 additions & 10 deletions packages/runtime/src/helpers/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -14,6 +16,12 @@ export interface ReasoningPart {
anthropic?: {
signature?: string
}
google?: {
thoughtSignature?: string
}
vertex?: {
thoughtSignature?: string
}
}
}

Expand All @@ -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<ThinkingPart, "id">[] {
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 }
}

/**
Expand Down
7 changes: 6 additions & 1 deletion packages/runtime/src/messages/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,15 @@ function thinkingPartToCoreThinkingPart(part: ThinkingPart): {
text: string
providerOptions?: Record<string, Record<string, string>>
} {
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 {
Expand Down