fix(ai): preserve provider tool input identity

This commit is contained in:
starptech 2026-07-19 00:54:08 +02:00
commit 0f26246bfd
21 changed files with 833 additions and 74 deletions

View file

@ -617,6 +617,43 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("preserves provider execution identity for malformed server tool input", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "server_tool_use", id: "srvtoolu_malformed", name: "web_search" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"partial' },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find((event) => event.type === "tool-input-start")).toMatchObject({
type: "tool-input-start",
id: "srvtoolu_malformed",
providerExecuted: true,
})
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
type: "tool-input-error",
id: "srvtoolu_malformed",
raw: '{"query":"partial',
providerExecuted: true,
})
}),
)
it.effect("decodes web_search_tool_result_error as provider-executed error result", () =>
Effect.gen(function* () {
const body = sseEvents(
@ -708,6 +745,55 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("lowers synthetic server tool failures to valid Anthropic error payloads", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicBody>(
LLM.request({
id: "req_server_tool_input_error",
model,
messages: [
Message.assistant([
{
type: "tool-call",
id: "srvtoolu_malformed",
name: "web_search",
input: {},
providerExecuted: true,
},
{
type: "tool-result",
id: "srvtoolu_malformed",
name: "web_search",
result: {
type: "error",
value: {
error: { type: "provider.invalid-output" },
raw: '{"query":"partial',
},
},
providerExecuted: true,
},
]),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{
role: "assistant",
content: [
{ type: "server_tool_use", id: "srvtoolu_malformed", name: "web_search", input: {} },
{
type: "web_search_tool_result",
tool_use_id: "srvtoolu_malformed",
content: { type: "web_search_tool_result_error", error_code: "invalid_tool_input" },
},
],
},
])
}),
)
it.effect("rejects round-trip for unknown server tool names", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(

View file

@ -303,6 +303,36 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("emits malformed streamed tool input without a tool call", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockStart",
{
contentBlockIndex: 0,
start: { toolUse: { toolUseId: "tool_malformed", name: "lookup" } },
},
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"partial' } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "tool_use" }],
)
const response = yield* LLMClient.generate(
LLM.updateRequest(baseRequest, {
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedBytes(body)))
expect(response.toolCalls).toEqual([])
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
type: "tool-input-error",
id: "tool_malformed",
raw: '{"query":"partial',
})
}),
)
it.effect("decodes reasoning deltas", () =>
Effect.gen(function* () {
const body = eventStreamBody(

View file

@ -490,6 +490,35 @@ describe("Gemini route", () => {
}),
)
it.effect("reports string-encoded function arguments without repairing them", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: '{"query":"partial' } }],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toEqual([])
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
type: "tool-input-error",
id: "tool_0",
name: "lookup",
raw: '{"query":"partial',
})
}),
)
it.effect("assigns unique ids to multiple streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents({

View file

@ -606,6 +606,33 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves a valid parallel call when another call is malformed", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
role: "assistant",
tool_calls: [
{ index: 0, id: "call_valid", function: { name: "lookup", arguments: '{"query":"weather"}' } },
{ index: 1, id: "call_malformed", function: { name: "lookup", arguments: '{"query":"partial' } },
],
}),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(
response.events.filter((event) => event.type === "tool-call" || event.type === "tool-input-error"),
).toMatchObject([
{ type: "tool-call", id: "call_valid", input: { query: "weather" } },
{ type: "tool-input-error", id: "call_malformed", raw: '{"query":"partial' },
])
}),
)
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () {
const body = sseEvents(

View file

@ -1238,6 +1238,7 @@ describe("OpenAI Responses route", () => {
type: "tool-input-end",
id: "call_1",
name: "lookup",
input: '{"query":"weather"}',
providerMetadata: { openai: { itemId: "item_1" } },
},
{
@ -1259,6 +1260,87 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("emits malformed function input when output_item.done arrives without added", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_malformed",
call_id: "call_malformed",
name: "lookup",
arguments: '{"query":"partial',
},
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(
response.events.filter(
(event) =>
event.type === "tool-input-start" || event.type === "tool-input-end" || event.type === "tool-input-error",
),
).toMatchObject([
{ type: "tool-input-start", id: "call_malformed", name: "lookup" },
{
type: "tool-input-end",
id: "call_malformed",
name: "lookup",
input: '{"query":"partial',
},
{
type: "tool-input-error",
id: "call_malformed",
name: "lookup",
raw: '{"query":"partial',
},
])
}),
)
it.effect("uses malformed final function input instead of valid streamed deltas", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"valid"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
},
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find((event) => event.type === "tool-input-end")).toMatchObject({
type: "tool-input-end",
input: '{"query":"partial',
})
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
type: "tool-input-error",
raw: '{"query":"partial',
})
}),
)
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {

View file

@ -57,32 +57,61 @@ describe("ToolStream", () => {
expect(finished).toEqual({
tools: {},
events: [
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-input-end", id: "call_1", name: "lookup", input: '{"query":"final"}' },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } },
],
})
}),
)
it.effect("classifies malformed tool input with its raw arguments", () =>
it.effect("emits malformed tool input with stable identity and raw arguments", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_1",
name: "lookup",
input: '{"query":"partial',
})
const error = yield* ToolStream.finish(ADAPTER, tools, 0).pipe(Effect.flip)
const finished = yield* ToolStream.finish(ADAPTER, tools, 0)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
source: "tool-input",
toolName: "lookup",
raw: '{"query":"partial',
expect(finished).toMatchObject({
tools: {},
events: [
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{
type: "tool-input-error",
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
message: "Invalid JSON input for test-route tool call lookup",
},
],
})
}),
)
it.effect("preserves valid sibling calls when one input is malformed", () =>
Effect.gen(function* () {
const first = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_valid",
name: "lookup",
input: '{"query":"weather"}',
})
const tools = ToolStream.start(first, 1, {
id: "call_malformed",
name: "lookup",
input: '{"query":"partial',
})
const finished = yield* ToolStream.finishAll(ADAPTER, tools)
expect(
finished.events.filter((event) => event.type === "tool-call" || event.type === "tool-input-error"),
).toMatchObject([
{ type: "tool-call", id: "call_valid", input: { query: "weather" } },
{ type: "tool-input-error", id: "call_malformed", raw: '{"query":"partial' },
])
}),
)
it.effect("preserves providerExecuted and clears all tools", () =>
Effect.gen(function* () {
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {