fix(ai): preserve provider tool input identity
This commit is contained in:
parent
7840a0d378
commit
0f26246bfd
21 changed files with 833 additions and 74 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue