diff --git a/.changeset/openai-compat-parallel-tool-calls.md b/.changeset/openai-compat-parallel-tool-calls.md new file mode 100644 index 00000000000..d2b2ce44124 --- /dev/null +++ b/.changeset/openai-compat-parallel-tool-calls.md @@ -0,0 +1,5 @@ +--- +"@effect/ai-openai-compat": patch +--- + +Group consecutive tool calls into one assistant message when using Chat Completions APIs. diff --git a/packages/ai/openai-compat/src/OpenAiLanguageModel.ts b/packages/ai/openai-compat/src/OpenAiLanguageModel.ts index f4ef5278a8c..6eb6de04c25 100644 --- a/packages/ai/openai-compat/src/OpenAiLanguageModel.ts +++ b/packages/ai/openai-compat/src/OpenAiLanguageModel.ts @@ -36,6 +36,7 @@ import * as InternalUtilities from "./internal/utilities.ts" import { type Annotation, type ChatCompletionContentPart, + type ChatCompletionRequestToolCall, type CreateResponse, type CreateResponse200, type CreateResponse200Sse, @@ -1666,7 +1667,24 @@ const toChatMessages = ( const messages: Array = [] for (const item of input) { - messages.push(...toChatMessagesFromItem(item)) + if (Predicate.hasProperty(item, "type") && item.type === "function_call") { + const previous = messages.at(-1) + const toolCall = toChatToolCall(item) + if (previous?.role === "assistant" && previous.tool_calls !== undefined) { + messages[messages.length - 1] = { + ...previous, + tool_calls: [...previous.tool_calls, toolCall] + } + } else { + messages.push({ + role: "assistant", + content: null, + tool_calls: [toolCall] + }) + } + } else { + messages.push(...toChatMessagesFromItem(item)) + } } return messages @@ -1694,14 +1712,7 @@ const toChatMessagesFromItem = ( return [{ role: "assistant", content: null, - tool_calls: [{ - id: item.call_id, - type: "function", - function: { - name: item.name, - arguments: item.arguments - } - }] + tool_calls: [toChatToolCall(item)] }] } @@ -1719,6 +1730,17 @@ const toChatMessagesFromItem = ( } } +const toChatToolCall = ( + item: Extract +): ChatCompletionRequestToolCall => ({ + id: item.call_id, + type: "function", + function: { + name: item.name, + arguments: item.arguments + } +}) + const toAssistantChatMessageContent = ( content: ReadonlyArray<{ readonly type: string diff --git a/packages/ai/openai-compat/test/OpenAiLanguageModel.test.ts b/packages/ai/openai-compat/test/OpenAiLanguageModel.test.ts index 1aa1eddb674..98d12bdb5a1 100644 --- a/packages/ai/openai-compat/test/OpenAiLanguageModel.test.ts +++ b/packages/ai/openai-compat/test/OpenAiLanguageModel.test.ts @@ -329,6 +329,94 @@ describe("OpenAiLanguageModel", () => { assert.strictEqual(functionTool.function.strict, true) })) + it.effect("groups parallel tool calls into one assistant message", () => + Effect.gen(function*() { + let capturedRequest: HttpClientRequest.HttpClientRequest | undefined + + const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe( + Layer.provide(Layer.succeed( + HttpClient.HttpClient, + makeHttpClient((request) => { + capturedRequest = request + return Effect.succeed(jsonResponse(request, makeChatCompletion())) + }) + )) + ) + + yield* LanguageModel.generateText({ + prompt: Prompt.make([ + { role: "user", content: "use both tools" }, + { + role: "assistant", + content: [ + Prompt.toolCallPart({ + id: "call_1", + name: "TestTool", + params: { input: "first" }, + providerExecuted: false + }), + Prompt.toolCallPart({ + id: "call_2", + name: "TestTool", + params: { input: "second" }, + providerExecuted: false + }) + ] + }, + { + role: "tool", + content: [ + Prompt.toolResultPart({ + id: "call_1", + name: "TestTool", + isFailure: false, + result: { output: "first" } + }), + Prompt.toolResultPart({ + id: "call_2", + name: "TestTool", + isFailure: false, + result: { output: "second" } + }) + ] + } + ]), + toolkit: TestToolkit + }).pipe( + Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")), + Effect.provide(TestToolkitLayer), + Effect.provide(layer) + ) + + assert.isDefined(capturedRequest) + if (capturedRequest === undefined) { + return + } + + const requestBody = yield* getRequestBody(capturedRequest) + assert.deepStrictEqual(requestBody.messages, [ + { role: "user", content: "use both tools" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "TestTool", arguments: JSON.stringify({ input: "first" }) } + }, + { + id: "call_2", + type: "function", + function: { name: "TestTool", arguments: JSON.stringify({ input: "second" }) } + } + ] + }, + { role: "tool", tool_call_id: "call_1", content: JSON.stringify({ output: "first" }) }, + { role: "tool", tool_call_id: "call_2", content: JSON.stringify({ output: "second" }) } + ]) + })) + it.effect("converts dynamic tools to function type", () => Effect.gen(function*() { let capturedRequest: HttpClientRequest.HttpClientRequest | undefined