Files
oh-my-pi/packages/ai/test/openai-tool-strict-mode.test.ts
T
can1357 291b3c74c2 feat: enhanced model reasoning, schema normalization, and loop guarding
- Integrated comprehensive loop guard support for DeepSeek and assistant prose patterns, including configurable stream checks.
- Implemented Moonshot Flavored JSON Schema (MFJS) normalization for improved tool compatibility and enum type inference.
- Added support for Ollama reasoning effort backfilling and Grok-specific service tier cost tracking across providers.
- Expanded model catalog with new entries and unified compatibility logic for improved OpenRouter API integration.
2026-06-18 04:51:43 +02:00

627 lines
20 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions";
import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses";
import type {
Context,
FetchImpl,
Model,
ModelSpec,
OpenAICompat,
ProviderSessionState,
Tool,
} from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { type } from "arktype";
const testTool: Tool = {
name: "echo",
description: "Echo input",
parameters: type({
text: "string",
}),
};
const looseYieldTool: Tool = {
name: "yield",
description: "Submit result",
strict: false,
parameters: {
type: "object",
additionalProperties: false,
properties: {
result: {
anyOf: [
{
type: "object",
additionalProperties: false,
properties: {
data: {
type: "object",
additionalProperties: true,
},
},
required: ["data"],
},
],
},
},
required: ["result"],
},
};
const testContext: Context = {
messages: [
{
role: "user",
content: "say hi",
timestamp: Date.now(),
},
],
tools: [testTool],
};
function createAbortedSignal(): AbortSignal {
const controller = new AbortController();
controller.abort();
return controller.signal;
}
function toRecord(value: unknown): Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function getYieldDataSchema(parameters: unknown): Record<string, unknown> {
const resultSchema = toRecord(toRecord(parameters).properties).result;
const variants = toRecord(resultSchema).anyOf;
if (!Array.isArray(variants)) return {};
for (const variant of variants) {
const dataSchema = toRecord(toRecord(variant).properties).data;
if (dataSchema !== undefined) return toRecord(dataSchema);
}
return {};
}
function createSseResponse(events: unknown[]): Response {
const payload = `${events.map(event => `data: ${typeof event === "string" ? event : JSON.stringify(event)}`).join("\n\n")}\n\n`;
return new Response(payload, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
function captureCompletionsPayload(
model: Model<"openai-completions">,
context: Context = testContext,
): Promise<unknown> {
const { promise, resolve } = Promise.withResolvers<unknown>();
streamOpenAICompletions(model, context, {
apiKey: "test-key",
signal: createAbortedSignal(),
onPayload: payload => resolve(payload),
});
return promise;
}
function captureResponsesPayload(model: Model<"openai-responses">, context: Context = testContext): Promise<unknown> {
const { promise, resolve } = Promise.withResolvers<unknown>();
streamOpenAIResponses(model, context, {
apiKey: "test-key",
signal: createAbortedSignal(),
onPayload: payload => resolve(payload),
});
return promise;
}
describe("OpenAI tool strict mode", () => {
it("sends strict=true for openai-completions tool schemas", async () => {
const model: Model<"openai-completions"> = {
...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">),
api: "openai-completions",
};
const payload = (await captureCompletionsPayload(model)) as {
tools?: Array<{ function?: { strict?: boolean } }>;
};
expect(payload.tools?.[0]?.function?.strict).toBe(true);
});
it("omits strict for openai-completions when compatibility disables strict mode", async () => {
const model: Model<"openai-completions"> = buildModel({
...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">),
api: "openai-completions",
compat: { supportsStrictMode: false } satisfies OpenAICompat,
} as ModelSpec<"openai-completions">);
const payload = (await captureCompletionsPayload(model)) as {
tools?: Array<{ function?: { strict?: boolean } }>;
};
expect(payload.tools?.[0]?.function?.strict).toBeUndefined();
});
it("keeps loose yield schemas non-strict for openai-completions", async () => {
const model: Model<"openai-completions"> = {
...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">),
api: "openai-completions",
};
const payload = (await captureCompletionsPayload(model, {
...testContext,
tools: [looseYieldTool],
})) as {
tools?: Array<{ function?: { strict?: boolean; parameters?: Record<string, unknown> } }>;
};
const fn = payload.tools?.[0]?.function;
expect(fn?.strict).toBeUndefined();
expect(getYieldDataSchema(fn?.parameters).additionalProperties).toBe(true);
});
it("sends strict=true for openai-completions tool schemas on GitHub Copilot", async () => {
const model = getBundledModel("github-copilot", "gpt-4o") as Model<"openai-completions">;
const payload = (await captureCompletionsPayload(model)) as {
tools?: Array<{ function?: { strict?: boolean } }>;
};
expect(payload.tools?.[0]?.function?.strict).toBe(true);
});
it("sends strict=true for openai-completions tool schemas on OpenRouter", async () => {
const model = getBundledModel("openrouter", "anthropic/claude-sonnet-4") as Model<"openai-completions">;
const payload = (await captureCompletionsPayload(model)) as {
tools?: Array<{ function?: { strict?: boolean } }>;
};
expect(payload.tools?.[0]?.function?.strict).toBe(true);
});
it("omits stream_options usage requests for Cerebras chat completions", async () => {
const model = getBundledModel("cerebras", "gpt-oss-120b") as Model<"openai-completions">;
const payload = (await captureCompletionsPayload(model)) as {
stream_options?: { include_usage?: boolean };
};
expect(payload.stream_options).toBeUndefined();
});
it("uses uniformly non-strict tool schemas when provider requires all-or-none strictness", async () => {
const model: Model<"openai-completions"> = buildModel({
...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">),
api: "openai-completions",
compat: { toolStrictMode: "all_strict" } satisfies OpenAICompat,
} as ModelSpec<"openai-completions">);
const context: Context = {
...testContext,
tools: [
testTool,
{
name: "dynamic_map",
description: "Dynamic object map",
parameters: type({
values: "Record<string, string>?",
}),
},
],
};
const payload = (await captureCompletionsPayload(model, context)) as {
tools?: Array<{ function?: { strict?: boolean } }>;
};
expect(payload.tools).toHaveLength(2);
expect(payload.tools?.every(tool => tool.function?.strict === undefined)).toBe(true);
});
it("surfaces captured JSON error bodies when the SDK reports no body", async () => {
const model: Model<"openai-completions"> = {
...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">),
api: "openai-completions",
};
const fetchMock: FetchImpl = Object.assign(
async (_input: string | URL | Request, _init?: RequestInit): Promise<Response> =>
new Response(
JSON.stringify({
message: "Tools with mixed values for 'strict' are not allowed.",
type: "invalid_request_error",
param: "tools",
code: "wrong_api_format",
}),
{
status: 422,
headers: { "content-type": "application/json" },
},
),
{ preconnect: fetch.preconnect },
);
const result = await streamOpenAICompletions(model, testContext, {
apiKey: "test-key",
fetch: fetchMock,
}).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Tools with mixed values for 'strict' are not allowed.");
expect(result.errorMessage).toContain("param=tools");
expect(result.errorMessage).toContain("code=wrong_api_format");
});
it("retries with non-strict tool schemas after strict-mode request errors", async () => {
const model: Model<"openai-completions"> = buildModel({
...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">),
api: "openai-completions",
compat: { toolStrictMode: "all_strict" } satisfies OpenAICompat,
} as ModelSpec<"openai-completions">);
const strictFlags: boolean[][] = [];
const fetchMock: FetchImpl = Object.assign(
async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const bodyText = typeof init?.body === "string" ? init.body : "";
const payload = JSON.parse(bodyText) as {
tools?: Array<{ function?: { strict?: boolean } }>;
};
strictFlags.push((payload.tools ?? []).map(tool => tool.function?.strict === true));
if (strictFlags.length === 1) {
return new Response(
JSON.stringify({
message: "Strict tool schema validation failed.",
type: "invalid_request_error",
param: "tools",
code: "wrong_api_format",
}),
{
status: 422,
headers: { "content-type": "application/json" },
},
);
}
return createSseResponse([
{
id: "chatcmpl-retry",
object: "chat.completion.chunk",
created: 0,
model: model.id,
choices: [{ index: 0, delta: { content: "Hello" } }],
},
{
id: "chatcmpl-retry",
object: "chat.completion.chunk",
created: 0,
model: model.id,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
},
"[DONE]",
]);
},
{ preconnect: fetch.preconnect },
);
const result = await streamOpenAICompletions(model, testContext, {
apiKey: "test-key",
fetch: fetchMock,
}).result();
expect(result.stopReason).toBe("stop");
expect(result.content).toContainEqual({ type: "text", text: "Hello" });
expect(strictFlags).toEqual([[true], [false]]);
});
it("keeps OpenRouter Anthropic tools non-strict after compiled grammar errors", async () => {
const model = getBundledModel("openrouter", "anthropic/claude-sonnet-4") as Model<"openai-completions">;
const providerSessionState = new Map<string, ProviderSessionState>();
const strictFlags: boolean[][] = [];
let attempt = 0;
const fetchMock: FetchImpl = Object.assign(
async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
attempt += 1;
const bodyText = typeof init?.body === "string" ? init.body : "";
const payload = JSON.parse(bodyText) as {
tools?: Array<{ function?: { strict?: boolean } }>;
};
strictFlags.push((payload.tools ?? []).map(tool => tool.function?.strict === true));
if (attempt === 1) {
return new Response(
JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message:
"The compiled grammar is too large, which would cause performance issues. Simplify your tool schemas or reduce the number of strict tools.",
},
request_id: "req_test",
}),
{
status: 400,
headers: { "content-type": "application/json" },
},
);
}
return createSseResponse([
{
id: "chatcmpl-openrouter-retry",
object: "chat.completion.chunk",
created: 0,
model: model.id,
choices: [{ index: 0, delta: { content: attempt === 2 ? "Recovered" : "Later" } }],
},
{
id: "chatcmpl-openrouter-retry",
object: "chat.completion.chunk",
created: 0,
model: model.id,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
},
"[DONE]",
]);
},
{ preconnect: fetch.preconnect },
);
const result = await streamOpenAICompletions(model, testContext, {
apiKey: "test-key",
providerSessionState,
fetch: fetchMock,
}).result();
expect(result.stopReason).toBe("stop");
// A successful strict-grammar fallback must NOT leak the original 400 onto
// the done message — agent.ts records errorMessage as turn error regardless
// of stopReason, so a non-empty errorMessage here mis-flags a clean turn.
expect(result.errorMessage).toBeUndefined();
expect(result.content).toContainEqual({ type: "text", text: "Recovered" });
expect(strictFlags).toEqual([[true], [false]]);
const nextResult = await streamOpenAICompletions(model, testContext, {
apiKey: "test-key",
providerSessionState,
fetch: fetchMock,
}).result();
expect(nextResult.stopReason).toBe("stop");
expect(nextResult.content).toContainEqual({ type: "text", text: "Later" });
expect(strictFlags).toEqual([[true], [false], [false]]);
});
it("clears errorMessage on a successful OpenRouter Anthropic compiled-grammar fallback (responses)", async () => {
const model = buildModel({
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4 via OpenRouter Responses",
api: "openai-responses",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 131_072,
} as ModelSpec<"openai-responses">);
const providerSessionState = new Map<string, ProviderSessionState>();
const strictFlags: boolean[][] = [];
let attempt = 0;
const fetchMock: FetchImpl = Object.assign(
async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
attempt += 1;
const bodyText = typeof init?.body === "string" ? init.body : "";
const payload = JSON.parse(bodyText) as { tools?: Array<{ strict?: boolean }> };
strictFlags.push((payload.tools ?? []).map(tool => tool.strict === true));
if (attempt === 1) {
return new Response(
JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message:
"The compiled grammar is too large, which would cause performance issues. Simplify your tool schemas or reduce the number of strict tools.",
},
request_id: "req_test",
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
return createSseResponse([
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
},
{
type: "response.content_part.added",
item_id: "msg_1",
output_index: 0,
content_index: 0,
part: { type: "output_text", text: "" },
},
{
type: "response.output_text.delta",
item_id: "msg_1",
output_index: 0,
content_index: 0,
delta: "Recovered",
},
{
type: "response.output_text.done",
item_id: "msg_1",
output_index: 0,
content_index: 0,
text: "Recovered",
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Recovered" }],
},
},
{
type: "response.completed",
response: {
status: "completed",
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
input_tokens_details: { cached_tokens: 0 },
},
},
},
]);
},
{ preconnect: fetch.preconnect },
);
const result = await streamOpenAIResponses(model, testContext, {
apiKey: "test-key",
providerSessionState,
fetch: fetchMock,
}).result();
const text = result.content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map(block => block.text)
.join("");
expect(result.stopReason).toBe("stop");
// A successful strict-grammar fallback must NOT leak the original 400 onto
// the done message (mirrors the completions path).
expect(result.errorMessage).toBeUndefined();
expect(text).toBe("Recovered");
expect(strictFlags).toEqual([[true], [false]]);
});
it("does not disable OpenRouter Anthropic strict tools for unrelated invalid requests", async () => {
const model = getBundledModel("openrouter", "anthropic/claude-sonnet-4") as Model<"openai-completions">;
const providerSessionState = new Map<string, ProviderSessionState>();
const strictFlags: boolean[][] = [];
const fetchMock: FetchImpl = Object.assign(
async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const bodyText = typeof init?.body === "string" ? init.body : "";
const payload = JSON.parse(bodyText) as {
tools?: Array<{ function?: { strict?: boolean } }>;
};
strictFlags.push((payload.tools ?? []).map(tool => tool.function?.strict === true));
return new Response(
JSON.stringify({
type: "error",
error: { type: "invalid_request_error", message: "Some other validation error." },
request_id: "req_test",
}),
{
status: 400,
headers: { "content-type": "application/json" },
},
);
},
{ preconnect: fetch.preconnect },
);
const result = await streamOpenAICompletions(model, testContext, {
apiKey: "test-key",
providerSessionState,
fetch: fetchMock,
}).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Some other validation error");
expect(strictFlags).toEqual([[true]]);
});
it("falls back to non-strict tools when an upstream validator rejects strict schemas, and remembers it", async () => {
const model = getBundledModel("openrouter", "deepseek/deepseek-v4-flash") as Model<"openai-completions">;
const providerSessionState = new Map<string, ProviderSessionState>();
const strictFlags: boolean[][] = [];
let attempt = 0;
const fetchMock: FetchImpl = Object.assign(
async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
attempt += 1;
const bodyText = typeof init?.body === "string" ? init.body : "";
const payload = JSON.parse(bodyText) as {
tools?: Array<{ function?: { strict?: boolean } }>;
};
strictFlags.push((payload.tools ?? []).map(tool => tool.function?.strict === true));
if (attempt === 1) {
return new Response(
JSON.stringify({
error: {
message: "Invalid tool parameters schema : field `anyOf`: missing field `type`",
type: "invalid_request_error",
},
}),
{
status: 400,
headers: { "content-type": "application/json" },
},
);
}
return createSseResponse([
{
id: "chatcmpl-deepseek-retry",
object: "chat.completion.chunk",
created: 0,
model: model.id,
choices: [{ index: 0, delta: { content: attempt === 2 ? "Recovered" : "Later" } }],
},
{
id: "chatcmpl-deepseek-retry",
object: "chat.completion.chunk",
created: 0,
model: model.id,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
},
"[DONE]",
]);
},
{ preconnect: fetch.preconnect },
);
const result = await streamOpenAICompletions(model, testContext, {
apiKey: "test-key",
providerSessionState,
fetch: fetchMock,
}).result();
expect(result.stopReason).toBe("stop");
expect(result.content).toContainEqual({ type: "text", text: "Recovered" });
expect(strictFlags).toEqual([[true], [false]]);
// The schema is static per session — later requests skip the doomed strict attempt.
const nextResult = await streamOpenAICompletions(model, testContext, {
apiKey: "test-key",
providerSessionState,
fetch: fetchMock,
}).result();
expect(nextResult.stopReason).toBe("stop");
expect(nextResult.content).toContainEqual({ type: "text", text: "Later" });
expect(strictFlags).toEqual([[true], [false], [false]]);
});
it("sends strict=true for openai-responses tool schemas on OpenAI", async () => {
const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
const payload = (await captureResponsesPayload(model)) as {
tools?: Array<{ strict?: boolean }>;
};
expect(payload.tools?.[0]?.strict).toBe(true);
});
it("keeps loose yield schemas non-strict for openai-responses", async () => {
const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
const payload = (await captureResponsesPayload(model, {
...testContext,
tools: [looseYieldTool],
})) as {
tools?: Array<{ strict?: boolean; parameters?: Record<string, unknown> }>;
};
const tool = payload.tools?.[0];
expect(tool?.strict).toBeUndefined();
expect(getYieldDataSchema(tool?.parameters).additionalProperties).toBe(true);
});
it("sends strict=true for openai-responses tool schemas on GitHub Copilot", async () => {
const model = getBundledModel("github-copilot", "gpt-5-mini") as Model<"openai-responses">;
const payload = (await captureResponsesPayload(model)) as {
tools?: Array<{ strict?: boolean }>;
};
expect(payload.tools?.[0]?.strict).toBe(true);
});
});