Skip to content

Commit 0722517

Browse files
authored
fix(ai): serialize undefined historical tool input (#48863)
1 parent 223b027 commit 0722517

3 files changed

Lines changed: 75 additions & 2 deletions

File tree

‎packages/ai/src/protocols/open-responses.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
522522
call_id: part.id,
523523
name: part.name,
524524
namespace: part.namespace,
525-
arguments: ProviderShared.encodeJson(part.input),
525+
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
526526
}
527527
}
528528

‎packages/ai/src/protocols/openai-chat.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChat
315315
type: "function",
316316
function: {
317317
name: part.name,
318-
arguments: ProviderShared.encodeJson(part.input),
318+
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
319319
},
320320
})
321321

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { expect } from "bun:test"
2+
import { Effect, Schema } from "effect"
3+
import { LLM, Message, ToolCallPart } from "../../src/index.js"
4+
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
5+
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
6+
import { Auth } from "../../src/route/auth.js"
7+
import { compileRequest } from "../../src/route/client.js"
8+
import { it } from "../lib/effect.js"
9+
10+
for (const route of [OpenAIChat.route, OpenAIResponses.route]) {
11+
const model = route
12+
.with({ endpoint: { baseURL: "https://api.openai.test/v1" }, auth: Auth.bearer("test") })
13+
.model({ id: "gpt-4.1-mini" })
14+
const chat = route.id === "openai-chat"
15+
16+
it.effect(`${route.id} serializes schema-valid undefined historical tool input as an empty object`, () =>
17+
Effect.gen(function* () {
18+
const message = Schema.decodeUnknownSync(Message)({
19+
role: "assistant",
20+
content: [{ type: "tool-call", id: "call_1", name: "lookup", input: undefined }],
21+
})
22+
expect(Schema.is(Message)(message)).toBe(true)
23+
const prepared = yield* compileRequest(
24+
LLM.request({
25+
model,
26+
messages: [
27+
Message.user("Look up the value."),
28+
message,
29+
Message.tool({ id: "call_1", name: "lookup", result: "Missing input", resultType: "error" }),
30+
],
31+
}),
32+
)
33+
if (chat)
34+
expect(prepared.body.messages).toContainEqual({
35+
role: "assistant",
36+
content: null,
37+
tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }],
38+
})
39+
if (!chat)
40+
expect(prepared.body.input).toContainEqual({
41+
type: "function_call",
42+
call_id: "call_1",
43+
name: "lookup",
44+
arguments: "{}",
45+
})
46+
expect(message.content[0]).toEqual({ type: "tool-call", id: "call_1", name: "lookup", input: undefined })
47+
}),
48+
)
49+
50+
it.effect(`${route.id} preserves defined historical tool inputs`, () =>
51+
Effect.gen(function* () {
52+
for (const [input, encoded] of [
53+
[null, "null"],
54+
[[], "[]"],
55+
[42, "42"],
56+
["invalid", '"invalid"'],
57+
[{ value: "original" }, '{"value":"original"}'],
58+
] as const) {
59+
const prepared = yield* compileRequest(
60+
LLM.request({
61+
model,
62+
messages: [
63+
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input })]),
64+
Message.tool({ id: "call_1", name: "lookup", result: "Invalid input", resultType: "error" }),
65+
],
66+
}),
67+
)
68+
if (chat) expect(prepared.body.messages[0].tool_calls[0].function.arguments).toBe(encoded)
69+
if (!chat) expect(prepared.body.input[0].arguments).toBe(encoded)
70+
}
71+
}),
72+
)
73+
}

0 commit comments

Comments
 (0)