diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 616d81d6aa..1252885335 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -212,7 +212,6 @@ type GeminiEvent = Schema.Schema.Type interface ParserState { readonly finishReason?: string readonly hasToolCalls: boolean - readonly nextToolCallId: number readonly promptFeedback?: GeminiPromptFeedback readonly usage?: Usage readonly lifecycle: Lifecycle.State @@ -580,7 +579,6 @@ const step = (state: ParserState, event: GeminiEvent) => { const events: LLMEvent[] = [] let hasToolCalls = nextState.hasToolCalls let lifecycle = nextState.lifecycle - let nextToolCallId = nextState.nextToolCallId let reasoningSignature = nextState.reasoningSignature let textSignature = nextState.textSignature @@ -620,7 +618,9 @@ const step = (state: ParserState, event: GeminiEvent) => { if ("functionCall" in part) { const input = part.functionCall.args === undefined ? {} : part.functionCall.args - const id = `tool_${nextToolCallId++}` + // Gemini 2.0+ and Vertex supply a unique function call ID on the part; when omitted (e.g. Gemini 1.5), + // generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries. + const id = part.functionCall.id ?? `tool_${crypto.randomUUID().replaceAll("-", "")}` const metadata = { ...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }), ...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }), @@ -649,7 +649,6 @@ const step = (state: ParserState, event: GeminiEvent) => { ...nextState, hasToolCalls, lifecycle, - nextToolCallId, reasoningSignature, textSignature, finishReason: candidate.finishReason ?? nextState.finishReason, @@ -673,7 +672,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(GeminiEvent), - initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }), + initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }), step, onHalt: finish, }, diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 01bd5a2281..0c6766822d 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -848,7 +848,7 @@ describe("Gemini route", () => { providerMetadata: { google: { thoughtSignature: "thought_sig" } }, }) expect(toolCall).toMatchObject({ - id: "tool_0", + id: "provider_call", providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } }, }) expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan( @@ -862,14 +862,14 @@ describe("Gemini route", () => { Message.assistant([ { type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata }, ToolCallPart.make({ - id: "tool_0", + id: "provider_call", name: "lookup", input: { query: "weather" }, providerMetadata: toolCall?.providerMetadata, }), ]), Message.tool({ - id: "tool_0", + id: "provider_call", name: "lookup", result: "done", resultType: "text", @@ -1101,21 +1101,17 @@ describe("Gemini route", () => { providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, }) - expect(response.toolCalls).toEqual([ - { - type: "tool-call", - id: "tool_0", - name: "lookup", - input: { query: "weather" }, - providerExecuted: undefined, - providerMetadata: undefined, - }, - ]) + expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/) + expect(response.toolCalls[0]).toMatchObject({ + type: "tool-call", + name: "lookup", + input: { query: "weather" }, + }) expect(response.events).toEqual([ { type: "step-start", index: 0 }, { type: "tool-call", - id: "tool_0", + id: response.toolCalls[0].id, name: "lookup", input: { query: "weather" }, providerExecuted: undefined, @@ -1158,7 +1154,8 @@ describe("Gemini route", () => { ), ) - expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }]) + expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/) + expect(response.toolCalls).toMatchObject([{ type: "tool-call", name: "ping", input: {} }]) }), ) @@ -1198,7 +1195,7 @@ describe("Gemini route", () => { content: { role: "model", parts: [ - { functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } }, + { functionCall: { id: "call_0", name: "lookup", args: { query: "weather" } } }, { functionCall: { name: "lookup", args: { query: "news" } } }, ], }, @@ -1212,16 +1209,20 @@ describe("Gemini route", () => { }), ).pipe(Effect.provide(fixedResponse(body))) - expect(response.toolCalls).toEqual([ - { - type: "tool-call", - id: "tool_0", - name: "lookup", - input: { query: "weather" }, - providerMetadata: { google: { functionCallId: "tool_0" } }, - }, - { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, - ]) + expect(response.toolCalls[0]).toMatchObject({ + type: "tool-call", + id: "call_0", + name: "lookup", + input: { query: "weather" }, + providerMetadata: { google: { functionCallId: "call_0" } }, + }) + expect(response.toolCalls[1]).toMatchObject({ + type: "tool-call", + name: "lookup", + input: { query: "news" }, + }) + expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/) + expect(response.toolCalls[0].id).not.toBe(response.toolCalls[1].id) expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: "tool-calls", raw: "STOP" }, @@ -1229,6 +1230,31 @@ describe("Gemini route", () => { }), ) + it.effect("assigns distinct unique fallback ids across separate requests", () => + Effect.gen(function* () { + const body = sseEvents({ + candidates: [ + { + content: { + role: "model", + parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }], + }, + finishReason: "STOP", + }, + ], + }) + const req = LLMRequest.update(request, { + tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], + }) + const first = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body))) + const second = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body))) + + expect(first.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/) + expect(second.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/) + expect(first.toolCalls[0].id).not.toBe(second.toolCalls[0].id) + }), + ) + it.effect("maps length and content-filter finish reasons", () => Effect.gen(function* () { const length = yield* LLMClient.generate(request).pipe(