Files
dev/third_party/pi/packages/ai/test/anthropic-strict-tool-fallback.test.ts
alexion 6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

289 lines
12 KiB
TypeScript

import Anthropic from "@anthropic-ai/sdk";
import type { BetaTool, MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/beta/messages/messages.js";
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
import { type AnthropicOptions, stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { stream as streamPiMessages } from "../src/api/pi-messages.ts";
import type { Model, Tool, TranscriptContext } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const grammarError =
"The compiled grammar is too large, which would cause performance issues. Simplify your tool schemas or reduce the number of strict tools.";
const model: Model<"anthropic-messages"> = {
id: "claude-haiku-4-5",
name: "Test Claude",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://anthropic.example",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 100,
compat: { supportsStrictTools: true },
};
function tool(name: string, strict?: "prefer" | "require"): Tool {
return {
name,
description: "Look up a value",
parameters: Type.Object({ value: Type.String(), optional: Type.Optional(Type.Number()) }),
...(strict ? { constrainedSampling: { type: "json_schema", strict } } : {}),
};
}
function context(tools: Tool[]): TranscriptContext {
return normalizeContext({ tools, messages: [{ role: "user", content: "Hello", timestamp: 1 }] });
}
function errorResponse(message = grammarError, status = 400, type = "invalid_request_error"): Response {
return Response.json({ type: "error", error: { type, message } }, { status });
}
function successResponse(): Response {
const events = [
{
type: "message_start",
message: { id: "msg_test", model: model.id, usage: { input_tokens: 10, output_tokens: 0 } },
},
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
];
return new Response(events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
});
}
function harness(respond: (payload: MessageCreateParamsStreaming, attempt: number) => Response) {
const payloads: MessageCreateParamsStreaming[] = [];
const fetch: NonNullable<AnthropicOptions["fetch"]> = async (_input, init) => {
const payload = JSON.parse(init?.body as string) as MessageCreateParamsStreaming;
payloads.push(payload);
return respond(payload, payloads.length);
};
return { payloads, options: { apiKey: "test-key", fetch, maxRetries: 0 } satisfies AnthropicOptions };
}
// Regression tests for https://github.com/earendil-works/pi/issues/10409.
describe("Anthropic preferred strict tool fallback", () => {
it.each([
grammarError,
// Observed from the live API: 32 strict tools, then 20 tools with 24 optional fields each.
"Too many strict tools (32). The maximum number of strict tools supported is 20. Try reducing the number of tools marked as strict.",
"Schemas contains too many parameters with union types (480 parameters with type arrays or anyOf). This causes exponential compilation cost. Reduce the number of nullable or union-typed parameters (limit: 16 parameters with unions).",
])("retries %s with original non-strict schemas and preserves required tools", async (message) => {
const preferred = tool("preferred", "prefer");
const required = tool("required", "require");
const ordinary = tool("ordinary");
const transcript = context([preferred, required, ordinary]);
const original = JSON.stringify(transcript);
// Use the real SDK's HTTP error parsing, not a synthetic thrown Error.
const { payloads, options } = harness((_payload, attempt) =>
attempt === 1 ? errorResponse(message) : successResponse(),
);
const onPayload = vi.fn((payload: unknown) => ({
...(payload as MessageCreateParamsStreaming),
inference_geo: "us",
stream: false,
}));
const onResponse = vi.fn();
const stream = streamAnthropic(model, transcript, { ...options, onPayload, onResponse, cacheRetention: "long" });
const events: string[] = [];
for await (const event of stream) events.push(event.type);
const result = await stream.result();
expect(result.stopReason).toBe("stop");
expect(events).toEqual(["start", "done"]);
expect(payloads).toHaveLength(2);
expect(onPayload).toHaveBeenCalledTimes(2);
expect(onResponse).toHaveBeenCalledTimes(1);
const first = payloads[0].tools as BetaTool[];
const second = payloads[1].tools as BetaTool[];
expect(first[0]).toMatchObject({
strict: true,
input_schema: {
required: ["value", "optional"],
properties: { optional: { anyOf: [{ type: "number" }, { type: "null" }] } },
},
});
expect(second[0].strict).toBeUndefined();
expect(second[0].input_schema).toEqual({
type: "object",
properties: { value: { type: "string" }, optional: { type: "number" } },
required: ["value"],
});
expect(second[1]).toEqual(first[1]);
expect(second[1].strict).toBe(true);
expect(second[2]).toEqual(first[2]);
expect(second[2].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
expect(payloads.map((payload) => payload.stream)).toEqual([true, true]);
expect(payloads[1]).toMatchObject({ inference_geo: "us" });
expect(JSON.stringify(transcript)).toBe(original);
expect(result.diagnostics?.[0]).toMatchObject({
type: "anthropic_strict_tools_fallback",
details: { reason: "strict_tool_limits_exceeded" },
});
});
it("downgrades inline additions and same-name redefinitions without downgrading require", async () => {
const transcript = context([tool("lookup", "require")]);
transcript.messages.push(
{ role: "system", content: "", toolsAdded: [tool("lookup", "prefer")], timestamp: 2 },
{ role: "system", content: "", toolsAdded: [tool("lookup", "require")], timestamp: 3 },
);
const { payloads, options } = harness((_payload, attempt) =>
attempt === 1 ? errorResponse() : successResponse(),
);
const result = await streamAnthropic(
{
...model,
compat: { ...model.compat, supportsMidConvoSystemMessages: true, supportsMidConvoToolChanges: true },
},
transcript,
{ ...options, apiKey: "sk-ant-oat-test" },
).result();
expect(result.stopReason).toBe("stop");
expect(payloads).toHaveLength(2);
expect(payloads[1].tools).toEqual(payloads[0].tools);
const additions = payloads.map((payload) =>
payload.messages.flatMap((message) =>
Array.isArray(message.content)
? message.content.flatMap((block) =>
block.type === "tool_addition" && block.tool.type === "tool_definition"
? [block.tool.definition as BetaTool]
: [],
)
: [],
),
);
expect(additions[0].map((definition) => definition.strict)).toEqual([true, true]);
expect(additions[1].map((definition) => definition.strict)).toEqual([undefined, true]);
expect(additions[1][0].input_schema.required).toEqual(["value"]);
expect(additions[1][1]).toEqual(additions[0][1]);
});
it.each([
["required", [tool("lookup", "require")]],
["ordinary", [tool("lookup")]],
[
"locally unsupported prefer",
[{ ...tool("lookup", "prefer"), parameters: Type.Object({ value: Type.Number({ minimum: 1 }) }) }],
],
])("does not retry a %s tool set", async (_name, tools) => {
const { payloads, options } = harness(() => errorResponse());
const result = await streamAnthropic(model, context(tools), options).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain(grammarError);
expect(payloads).toHaveLength(1);
});
it.each([
["unrelated 400", "Invalid tool schema", 400, "invalid_request_error"],
["wrong error type", grammarError, 400, "authentication_error"],
["wrong HTTP status", grammarError, 500, "invalid_request_error"],
])("does not downgrade on %s", async (_name, message, status, type) => {
const { payloads, options } = harness(() => errorResponse(message, status, type));
const result = await streamAnthropic(model, context([tool("lookup", "prefer")]), options).result();
expect(result.stopReason).toBe("error");
expect(payloads).toHaveLength(1);
});
it("surfaces a second rejection rather than retrying again", async () => {
const { payloads, options } = harness((_payload, attempt) =>
errorResponse(attempt === 1 ? grammarError : "Required grammar is still too large"),
);
const result = await streamAnthropic(
model,
context([tool("lookup", "prefer"), tool("required", "require")]),
options,
).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Required grammar is still too large");
expect(payloads).toHaveLength(2);
expect((payloads[1].tools as BetaTool[])[1].strict).toBe(true);
});
it("does not downgrade a hook exception, even if it looks like the provider rejection", async () => {
const { payloads, options } = harness(() => successResponse());
const result = await streamAnthropic(model, context([tool("lookup", "prefer")]), {
...options,
onPayload: () => {
throw Anthropic.APIError.generate(
400,
{ error: { type: "invalid_request_error", message: grammarError } },
grammarError,
new Headers(),
);
},
}).result();
expect(result.stopReason).toBe("error");
expect(payloads).toHaveLength(0);
expect(result.diagnostics).toBeUndefined();
});
it("honors cancellation before the fallback attempt", async () => {
const controller = new AbortController();
const { payloads, options } = harness(() => {
controller.abort();
return errorResponse();
});
const result = await streamAnthropic(model, context([tool("lookup", "prefer")]), {
...options,
signal: controller.signal,
}).result();
expect(result.stopReason).toBe("aborted");
expect(payloads).toHaveLength(1);
});
it("does not retry an SSE error after response headers", async () => {
const { payloads, options } = harness(
() =>
new Response(
`event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: grammarError } })}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
);
const result = await streamAnthropic(model, context([tool("lookup", "prefer")]), options).result();
expect(result.stopReason).toBe("error");
expect(payloads).toHaveLength(1);
});
it("does not disable strict sampling on later requests", async () => {
const { payloads, options } = harness((_payload, attempt) =>
attempt === 1 ? errorResponse() : successResponse(),
);
const transcript = context([tool("lookup", "prefer")]);
await streamAnthropic(model, transcript, options).result();
await streamAnthropic(model, transcript, options).result();
expect(payloads.map((payload) => (payload.tools as BetaTool[])[0].strict)).toEqual([true, undefined, true]);
});
it("preserves prefer through pi-messages so the gateway's Anthropic adapter can fall back", async () => {
const { payloads, options } = harness((_payload, attempt) =>
attempt === 1 ? errorResponse() : successResponse(),
);
const transcript = context([tool("lookup", "prefer")]);
const gatewayModel: Model<"pi-messages"> = {
...model,
api: "pi-messages",
provider: "radius",
baseUrl: "https://radius.example/v1",
compat: undefined,
};
const result = await streamPiMessages(gatewayModel, transcript, {
apiKey: "radius-test",
fetch: async (_input, init) => {
const request = JSON.parse(init?.body as string) as { context: TranscriptContext };
expect(request.context).toEqual(JSON.parse(JSON.stringify(transcript)));
const upstream = await streamAnthropic(model, request.context, options).result();
return new Response(
`data: ${JSON.stringify({ type: "done", reason: upstream.stopReason, usage: upstream.usage })}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
);
},
}).result();
expect(result.stopReason).toBe("stop");
expect(payloads.map((payload) => (payload.tools as BetaTool[])[0].strict)).toEqual([true, undefined]);
});
});