diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index b6c25a4d823..8018406e469 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -31,6 +31,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl protected options: ApiHandlerOptions protected provider: GoogleGenerativeAIProvider private readonly providerName = "Gemini" + private lastThoughtSignature: string | undefined constructor(options: ApiHandlerOptions) { super() @@ -124,11 +125,23 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } try { + // Reset thought signature for this request + this.lastThoughtSignature = undefined + // Use streamText for streaming responses const result = streamText(requestOptions) // Process the full stream to get all events including reasoning for await (const part of result.fullStream) { + // Capture thoughtSignature from tool-call events (Gemini 3 thought signatures) + // The AI SDK's tool-call event includes providerMetadata with the signature + if (part.type === "tool-call") { + const googleMeta = (part as any).providerMetadata?.google + if (googleMeta?.thoughtSignature) { + this.lastThoughtSignature = googleMeta.thoughtSignature + } + } + for (const chunk of processAiSdkStreamPart(part)) { yield chunk } @@ -401,4 +414,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl override isAiSdkProvider(): boolean { return true } + + /** + * Returns the thought signature captured from the last Gemini response. + * Gemini 3 models return thoughtSignature on function call parts, + * which must be round-tripped back for tool use continuations. + */ + getThoughtSignature(): string | undefined { + return this.lastThoughtSignature + } } diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index b2eb370c2f9..def3803922c 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -35,6 +35,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl protected options: ApiHandlerOptions protected provider: GoogleVertexProvider private readonly providerName = "Vertex" + private lastThoughtSignature: string | undefined constructor(options: ApiHandlerOptions) { super() @@ -138,11 +139,26 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl } try { + // Reset thought signature for this request + this.lastThoughtSignature = undefined + // Use streamText for streaming responses const result = streamText(requestOptions) // Process the full stream to get all events including reasoning for await (const part of result.fullStream) { + // Capture thoughtSignature from tool-call events (Gemini 3 thought signatures) + // The AI SDK's tool-call event includes providerMetadata with the signature + // Vertex AI stores it under the "vertex" key in providerMetadata + if (part.type === "tool-call") { + const vertexMeta = (part as any).providerMetadata?.vertex + const googleMeta = (part as any).providerMetadata?.google + const sig = vertexMeta?.thoughtSignature ?? googleMeta?.thoughtSignature + if (sig) { + this.lastThoughtSignature = sig + } + } + for (const chunk of processAiSdkStreamPart(part)) { yield chunk } @@ -406,4 +422,13 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl override isAiSdkProvider(): boolean { return true } + + /** + * Returns the thought signature captured from the last Vertex AI response. + * Gemini 3 models return thoughtSignature on function call parts, + * which must be round-tripped back for tool use continuations. + */ + getThoughtSignature(): string | undefined { + return this.lastThoughtSignature + } } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index fd112f2896f..3c1ca6d87e5 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -399,6 +399,101 @@ describe("AI SDK conversion utilities", () => { ], }) }) + + it("attaches thoughtSignature to first tool-call part for Gemini 3 round-tripping", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me check that." }, + { + type: "tool_use", + id: "tool-1", + name: "read_file", + input: { path: "test.txt" }, + }, + { type: "thoughtSignature", thoughtSignature: "encrypted-sig-abc" } as any, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + + expect(result).toHaveLength(1) + const assistantMsg = result[0] + expect(assistantMsg.role).toBe("assistant") + + const content = assistantMsg.content as any[] + expect(content).toHaveLength(2) // text + tool-call (thoughtSignature block is consumed, not passed through) + + const toolCallPart = content.find((p: any) => p.type === "tool-call") + expect(toolCallPart).toBeDefined() + expect(toolCallPart.providerOptions).toEqual({ + google: { thoughtSignature: "encrypted-sig-abc" }, + vertex: { thoughtSignature: "encrypted-sig-abc" }, + }) + }) + + it("attaches thoughtSignature only to the first tool-call in parallel calls", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "get_weather", + input: { city: "Paris" }, + }, + { + type: "tool_use", + id: "tool-2", + name: "get_weather", + input: { city: "London" }, + }, + { type: "thoughtSignature", thoughtSignature: "sig-parallel" } as any, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + const content = (result[0] as any).content as any[] + + const toolCalls = content.filter((p: any) => p.type === "tool-call") + expect(toolCalls).toHaveLength(2) + + // Only the first tool call should have the signature + expect(toolCalls[0].providerOptions).toEqual({ + google: { thoughtSignature: "sig-parallel" }, + vertex: { thoughtSignature: "sig-parallel" }, + }) + // Second tool call should NOT have the signature + expect(toolCalls[1].providerOptions).toBeUndefined() + }) + + it("does not attach providerOptions when no thoughtSignature block is present", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "Using tool" }, + { + type: "tool_use", + id: "tool-1", + name: "read_file", + input: { path: "test.txt" }, + }, + ], + }, + ] + + const result = convertToAiSdkMessages(messages) + const content = (result[0] as any).content as any[] + const toolCallPart = content.find((p: any) => p.type === "tool-call") + + expect(toolCallPart).toBeDefined() + expect(toolCallPart.providerOptions).toBeUndefined() + }) }) describe("convertToolsForAiSdk", () => { diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index 73f3131bef2..9b48ee57f79 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -136,8 +136,19 @@ export function convertToAiSdkMessages( toolCallId: string toolName: string input: unknown + providerOptions?: Record> }> = [] + // Extract thoughtSignature from content blocks (Gemini 3 thought signature round-tripping). + // Task.ts stores these as { type: "thoughtSignature", thoughtSignature: "..." } blocks. + let thoughtSignature: string | undefined + for (const part of message.content) { + const partAny = part as unknown as { type?: string; thoughtSignature?: string } + if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) { + thoughtSignature = partAny.thoughtSignature + } + } + for (const part of message.content) { if (part.type === "text") { textParts.push(part.text) @@ -145,12 +156,25 @@ export function convertToAiSdkMessages( } if (part.type === "tool_use") { - toolCalls.push({ + const toolCall: (typeof toolCalls)[number] = { type: "tool-call", toolCallId: part.id, toolName: part.name, input: part.input, - }) + } + + // Attach thoughtSignature as providerOptions on tool-call parts. + // The AI SDK's @ai-sdk/google provider reads providerOptions.google.thoughtSignature + // and attaches it to the Gemini functionCall part. + // Per Gemini 3 rules: only the FIRST functionCall in a parallel batch gets the signature. + if (thoughtSignature && toolCalls.length === 0) { + toolCall.providerOptions = { + google: { thoughtSignature }, + vertex: { thoughtSignature }, + } + } + + toolCalls.push(toolCall) continue } @@ -183,7 +207,13 @@ export function convertToAiSdkMessages( const content: Array< | { type: "reasoning"; text: string } | { type: "text"; text: string } - | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown } + | { + type: "tool-call" + toolCallId: string + toolName: string + input: unknown + providerOptions?: Record> + } > = [] if (reasoningContent) {