diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 817ad386f..1f96bd87e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/README.md b/packages/ai/README.md index 3eb24c1c6..34237bd2b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -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) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 4de461449..ba4a49373 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -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, diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index e28e12af5..4ac0b6206 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -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, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index eb67adfcd..7646a9c05 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -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"; diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index a57aa4ee5..7e0584ed0 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -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(