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
22 changes: 22 additions & 0 deletions src/api/providers/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}
25 changes: 25 additions & 0 deletions src/api/providers/vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}
95 changes: 95 additions & 0 deletions src/api/transform/__tests__/ai-sdk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
36 changes: 33 additions & 3 deletions src/api/transform/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,21 +136,45 @@ export function convertToAiSdkMessages(
toolCallId: string
toolName: string
input: unknown
providerOptions?: Record<string, Record<string, unknown>>
}> = []

// 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)
continue
}

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
}

Expand Down Expand Up @@ -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<string, Record<string, unknown>>
}
> = []

if (reasoningContent) {
Expand Down
Loading