diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 9ac85b07b139..62a56c1a05ad 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -53,6 +53,9 @@ const OpenAIChatAssistantToolCall = Schema.Struct({ name: Schema.String, arguments: Schema.String, }), + extra_content: Schema.optional( + Schema.Struct({ google: Schema.Struct({ thought_signature: Schema.optional(Schema.String) }) }), + ), }) type OpenAIChatAssistantToolCall = Schema.Schema.Type @@ -139,6 +142,9 @@ const OpenAIChatToolCallDelta = Schema.Struct({ index: Schema.Number, id: optionalNull(Schema.String), function: optionalNull(OpenAIChatToolCallDeltaFunction), + extra_content: optionalNull( + Schema.Struct({ google: Schema.Struct({ thought_signature: optionalNull(Schema.String) }) }), + ), }) type OpenAIChatToolCallDelta = Schema.Schema.Type @@ -193,6 +199,13 @@ const lowerToolChoice = (toolChoice: NonNullable) => tool: (name) => ({ type: "function" as const, function: { name } }), }) +const thoughtSignature = (part: ToolCallPart) => { + const google = part.providerMetadata?.google + return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string" + ? google.thoughtSignature + : undefined +} + const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({ id: part.id, type: "function", @@ -200,6 +213,9 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({ name: part.name, arguments: ProviderShared.encodeJson(part.input), }, + extra_content: thoughtSignature(part) + ? { google: { thought_signature: thoughtSignature(part) } } + : undefined, }) const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) { @@ -431,7 +447,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) => ADAPTER, tools, tool.index, - { id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" }, + { + id: tool.id ?? undefined, + name: tool.function?.name ?? undefined, + text: tool.function?.arguments ?? "", + providerMetadata: tool.extra_content?.google?.thought_signature + ? { google: { thoughtSignature: tool.extra_content.google.thought_signature } } + : undefined, + }, "OpenAI Chat tool call delta is missing id or name", ) if (ToolStream.isError(result)) return yield* result diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts index 8e07a64bfed8..59ab2e9df7de 100644 --- a/packages/llm/src/protocols/utils/tool-stream.ts +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -118,7 +118,12 @@ export const appendOrStart = ( route: string, tools: State, key: K, - delta: { readonly id?: string; readonly name?: string; readonly text: string }, + delta: { + readonly id?: string + readonly name?: string + readonly text: string + readonly providerMetadata?: ProviderMetadata + }, missingToolMessage: string, ): AppendOutcome | LLMError => { const current = tools[key] @@ -131,7 +136,7 @@ export const appendOrStart = ( name, input: `${current?.input ?? ""}${delta.text}`, providerExecuted: current?.providerExecuted, - providerMetadata: current?.providerMetadata, + providerMetadata: delta.providerMetadata ?? current?.providerMetadata, } if (current && delta.text.length === 0 && current.id === id && current.name === name) return { tools, tool: current, events: [] } diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index b736dc9dd33c..6df75af2cf07 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -552,14 +552,52 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("round-trips Google OpenAI-compatible tool thought signatures", () => + Effect.gen(function* () { + const call = ToolCallPart.make({ + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerMetadata: { google: { thoughtSignature: "tool_sig" } }, + }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [Message.user("Check the weather."), Message.assistant([call])], + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ) + + expect(prepared.body.messages[1]).toEqual({ + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "lookup", arguments: '{"query":"weather"}' }, + extra_content: { google: { thought_signature: "tool_sig" } }, + }, + ], + }) + }), + ) + it.effect("assembles streamed tool call input", () => Effect.gen(function* () { const body = sseEvents( deltaChunk({ role: "assistant", - tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }], + tool_calls: [ + { + index: 0, + id: "call_1", + function: { name: "lookup", arguments: '{"query"' }, + }, + ], }), deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), + deltaChunk({ tool_calls: [{ index: 0, extra_content: { google: { thought_signature: "tool_sig_late" } } }] }), deltaChunk({}, "tool_calls"), ) const response = yield* LLMClient.generate( @@ -568,19 +606,20 @@ describe("OpenAI Chat route", () => { }), ).pipe(Effect.provide(fixedResponse(body))) + const signatureMetadata = { google: { thoughtSignature: "tool_sig_late" } } expect(response.events).toEqual([ { type: "step-start", index: 0 }, - { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, + { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: signatureMetadata }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, - { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, + { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: signatureMetadata }, { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerExecuted: undefined, - providerMetadata: undefined, + providerMetadata: signatureMetadata, }, { type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined }, { type: "finish", reason: "tool-calls", usage: undefined },