From 8ab492494837a9d76191a9415c6c0e688b911c8f Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Sat, 25 Apr 2026 15:38:39 -0400 Subject: [PATCH] omp: add patch that fixes deepseek --- home/progs/pi.nix | 7 +- ...ithout-strict-on-deepseek-openrouter.patch | 126 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch diff --git a/home/progs/pi.nix b/home/progs/pi.nix index f014af5..fc1eef5 100644 --- a/home/progs/pi.nix +++ b/home/progs/pi.nix @@ -38,7 +38,12 @@ in { home.packages = [ (inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.omp.overrideAttrs (old: { - patches = (old.patches or [ ]) ++ [ ]; + patches = (old.patches or [ ]) ++ [ + # Retry without strict tools when DeepSeek (via OpenRouter) rejects strict-mode `anyOf` + # nullable unions with `Invalid tool parameters schema : field \`anyOf\`: missing field \`type\``. + # Upstream PR: pending; applies cleanly against v14.2.1. + ../../patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch + ]; })) ]; diff --git a/patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch b/patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch new file mode 100644 index 0000000..ff9429e --- /dev/null +++ b/patches/omp/0001-openai-completions-retry-without-strict-on-deepseek-openrouter.patch @@ -0,0 +1,126 @@ +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">;