Subject: [PATCH] fix(openai-completions): retry without strict tools for DeepSeek-via-OpenRouter anyOf rejections The retry-on-strict-tool-error path in openai-completions failed to recover when DeepSeek (and similar backends fronted by OpenRouter) reject strict-mode tool schemas with errors of the form: Invalid tool parameters schema : field `anyOf`: missing field `type` Two reasons: 1. Retry only triggered in "all_strict" mode. OpenRouter defaults to "mixed" (per-tool strict), so the early return prevented retry. 2. The error-message regex required "strict" near "tool". DeepSeek's message never mentions "strict". Fix: - Allow retry whenever any tool was sent with strict (i.e. mode != "none"). - Recognize "Invalid tool parameters" in the regex. Includes a regression test reproducing the exact DeepSeek error body via OpenRouter mixed-strict mode. Applies cleanly against v14.2.1. --- diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index e58189607..3c20631c1 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1245,7 +1245,10 @@ function shouldRetryWithoutStrictTools( toolStrictMode: AppliedToolStrictMode, tools: Tool[] | undefined, ): boolean { - if (!tools || tools.length === 0 || toolStrictMode !== "all_strict") { + // Retry whenever any tool was sent with `strict: true`. OpenRouter routes to underlying + // providers (e.g. DeepSeek) whose schema validators reject the strict-mode `anyOf` shape + // even when omp emitted strict per-tool ("mixed"), not just provider-wide ("all_strict"). + if (!tools || tools.length === 0 || toolStrictMode === "none") { return false; } const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status; @@ -1255,7 +1258,14 @@ function shouldRetryWithoutStrictTools( const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText] .filter((value): value is string => typeof value === "string" && value.trim().length > 0) .join("\n"); - return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool/i.test(messageParts); + // Patterns: + // - `wrong_api_format`, `mixed values for 'strict'`: OpenAI rejecting mixed strict flags. + // - `tool ... strict` / `strict ... tool`: generic strict-tool complaints. + // - `Invalid tool parameters schema`: DeepSeek (via OpenRouter) rejecting strict-mode + // nullable unions because their validator demands `type` alongside `anyOf`. + return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|invalid tool parameters/i.test( + messageParts, + ); } function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): { diff --git a/packages/ai/test/openai-tool-strict-mode.test.ts b/packages/ai/test/openai-tool-strict-mode.test.ts index 2bf17e6d8..24d5a09d5 100644 --- a/packages/ai/test/openai-tool-strict-mode.test.ts +++ b/packages/ai/test/openai-tool-strict-mode.test.ts @@ -231,6 +231,64 @@ describe("OpenAI tool strict mode", () => { expect(result.content).toContainEqual({ type: "text", text: "Hello" }); expect(strictFlags).toEqual([[true], [false]]); }); + it("retries with non-strict tool schemas when OpenRouter backend rejects strict anyOf nullable unions", async () => { + // Reproduces deepseek/deepseek-v4-pro via OpenRouter rejecting the strict-mode schema with: + // 400 Provider returned error + // {"error":{"message":"Invalid tool parameters schema : field `anyOf`: missing field `type`",...}} + // OpenRouter is in mixed-strict mode by default (per-tool strict), so the original retry condition + // (only "all_strict") prevented recovery. The retry now triggers whenever any tool sent strict=true. + const model = getBundledModel("openrouter", "deepseek/deepseek-v3.2") as Model<"openai-completions">; + const strictFlags: boolean[][] = []; + global.fetch = Object.assign( + async (_input: string | URL | Request, init?: RequestInit): Promise => { + 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({ + error: { + message: "Invalid tool parameters schema : field `anyOf`: missing field `type`", + type: "invalid_request_error", + param: null, + code: "invalid_request_error", + }, + }), + { + status: 400, + headers: { "content-type": "application/json" }, + }, + ); + } + return createSseResponse([ + { + id: "chatcmpl-or", + object: "chat.completion.chunk", + created: 0, + model: model.id, + choices: [{ index: 0, delta: { content: "Hello" } }], + }, + { + id: "chatcmpl-or", + object: "chat.completion.chunk", + created: 0, + model: model.id, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + "[DONE]", + ]); + }, + { preconnect: originalFetch.preconnect }, + ); + + const result = await streamOpenAICompletions(model, testContext, { apiKey: "test-key" }).result(); + expect(result.stopReason).toBe("stop"); + expect(result.content).toContainEqual({ type: "text", text: "Hello" }); + expect(strictFlags).toEqual([[true], [false]]); + }); + it("sends strict=true for openai-responses tool schemas on OpenAI", async () => { const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;