fix(ai): default unknown providers to non-strict tools (closes #9816)

This commit is contained in:
Armin Ronacher 2026-09-21 01:14:32 +02:00
parent 63787ee6ba
commit 890f920884
6 changed files with 87 additions and 5 deletions

View file

@ -6,6 +6,10 @@
- Added model image-input limit and cache-safe resize metadata to the generated catalog ([#9631](https://github.com/earendil-works/pi/issues/9631)).
### Fixed
- Fixed unknown OpenAI-compatible Chat Completions endpoints receiving strict tool schemas unless they explicitly advertise support, while preserving strict tools for capable built-in models ([#9816](https://github.com/earendil-works/pi/issues/9816)).
## [0.86.1] - 2026-09-20
### Added

View file

@ -1213,7 +1213,7 @@ interface OpenAICompletionsCompat {
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
supportsUsageInStreaming?: boolean; // Whether provider supports `stream_options: { include_usage: true }` (default: true)
supportsStrictMode?: boolean; // Whether provider supports `strict` in tool definitions (default: true)
supportsStrictMode?: boolean; // Whether provider supports `strict` in tool definitions (default: false; enabled in metadata for capable built-in models)
supportsOpenAIGrammarTools?: boolean; // Whether to emit OpenAI custom Lark/regex grammar tools; false falls back to normal function tools (default: false; the generated catalog enables it for capable models)
supportsMidConvoSystemMessages?: boolean; // Whether the model accepts system messages after the conversation started; false folds them into the leading prompt (default: false; the generated catalog enables it for verified models)
supportsMidConvoToolAdditions?: boolean; // Whether system messages can add tools mid-conversation via Kimi-style `tools` system messages; requires supportsMidConvoSystemMessages (default: false)

View file

@ -640,7 +640,7 @@ const OPENAI_COMPLETIONS_DEFAULT_COMPAT = {
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
supportsStrictMode: true,
supportsStrictMode: false,
supportsOpenAIGrammarTools: false,
supportsMidConvoSystemMessages: false,
supportsMidConvoToolAdditions: false,
@ -744,6 +744,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
// Preserve built-in behavior as explicit metadata against the conservative runtime default.
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isCerebras,
supportsOpenAIGrammarTools: false,
supportsMidConvoSystemMessages: false,

View file

@ -1661,7 +1661,8 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
zaiToolStream: false,
supportsThinkingTokenBudget: false,
thinkingTokenBudgetField: undefined,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isCerebras,
// OpenAI compatibility alone does not imply strict JSON-schema tool support.
supportsStrictMode: false,
supportsOpenAIGrammarTools: false,
supportsMidConvoSystemMessages: false,
supportsMidConvoToolAdditions: false,

View file

@ -731,7 +731,7 @@ export interface OpenAICompletionsCompat {
supportsMidConvoSystemMessages?: boolean;
/** Whether system messages can introduce additional tools mid-conversation. Requires `supportsMidConvoSystemMessages`. Default: false; the generated model catalog enables it for capable models. */
supportsMidConvoToolAdditions?: boolean;
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */
/** Whether the provider supports the `strict` field in tool definitions. Default: false; generated capable models enable it explicitly. */
supportsStrictMode?: boolean;
/** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content. */
cacheControlFormat?: "anthropic";

View file

@ -219,6 +219,82 @@ describe("openai-completions tool_choice", () => {
expect("strict" in (tool ?? {})).toBe(false);
});
it("defaults unknown OpenAI-compatible endpoints to non-strict tools", async () => {
// Regression test for #9816.
const model = {
...localOpenAICompletionsModel,
id: "local-model",
name: "Local Model",
} satisfies Model<"openai-completions">;
const tool: Tool = {
name: "ping",
description: "Ping tool",
parameters: Type.Object({
required: Type.String(),
optional: Type.Optional(Type.String()),
}),
constrainedSampling: { type: "json_schema", strict: "prefer" },
};
let payload: unknown;
await streamSimple(
model,
{
messages: [{ role: "user", content: "Call ping", timestamp: Date.now() }],
tools: [tool],
},
{
apiKey: "test",
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
const params = (payload ?? mockState.lastParams) as {
tools?: Array<{ function?: { strict?: boolean; parameters?: { required?: string[] } } }>;
};
const functionTool = params.tools?.[0]?.function;
expect(functionTool).not.toHaveProperty("strict");
expect(functionTool?.parameters?.required).toEqual(["required"]);
});
it("preserves strict tools for capable built-in Chat Completions models", async () => {
const model = getModel("groq", "openai/gpt-oss-20b")!;
expect(model.compat?.supportsStrictMode).toBe(true);
const tool: Tool = {
name: "ping",
description: "Ping tool",
parameters: Type.Object({
required: Type.String(),
optional: Type.Optional(Type.String()),
}),
constrainedSampling: { type: "json_schema", strict: "prefer" },
};
let payload: unknown;
await streamSimple(
model,
{
messages: [{ role: "user", content: "Call ping", timestamp: Date.now() }],
tools: [tool],
},
{
apiKey: "test",
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
const params = (payload ?? mockState.lastParams) as {
tools?: Array<{ function?: { strict?: boolean; parameters?: { required?: string[] } } }>;
};
const functionTool = params.tools?.[0]?.function;
expect(functionTool?.strict).toBe(true);
expect(functionTool?.parameters?.required).toEqual(["required", "optional"]);
});
it("maps Groq Qwen reasoning levels to default reasoning_effort", async () => {
const model = getModel("groq", "qwen/qwen3.6-27b")!;
let payload: unknown;
@ -1799,7 +1875,7 @@ describe("openai-completions tool_choice", () => {
thinkingFormat: "ant-ling",
supportsLongCacheRetention: false,
});
expect(model.compat?.supportsStrictMode).toBeUndefined();
expect(model.compat?.supportsStrictMode).toBe(true);
expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBeUndefined();
await streamSimple(