From 37e1191c215b047dd476495fa074d94a79cea305 Mon Sep 17 00:00:00 2001 From: L4XB Date: Fri, 11 Sep 2026 10:14:13 +0200 Subject: [PATCH] fix(agent-runtime): backfill reasoning_content for relayed DeepSeek models pi-ai only gave assistant messages without thinking an empty reasoning_content when the model was flagged `reasoning`. A DeepSeek model reached through a relay is not in the catalogue, so the flag is false, the backfill never ran, and a history in which only some turns carry thinking was sent as is; DeepSeek answers that mix with HTTP 400 while all-or-none is accepted (#223). Extend the pnpm patch on @earendil-works/pi-ai: after converting the messages, whenever any assistant message carries reasoning_content, the remaining assistant messages get an empty one. The per-message backfill for flagged models is unchanged. --- .../src/reasoning-content-backfill.test.ts | 88 +++++++++++++++++++ patches/@earendil-works__pi-ai@0.85.1.patch | 27 ++++++ pnpm-lock.yaml | 12 +-- 3 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 packages/agent-runtime/src/reasoning-content-backfill.test.ts diff --git a/packages/agent-runtime/src/reasoning-content-backfill.test.ts b/packages/agent-runtime/src/reasoning-content-backfill.test.ts new file mode 100644 index 000000000..0012f73a2 --- /dev/null +++ b/packages/agent-runtime/src/reasoning-content-backfill.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { convertMessages } from "@earendil-works/pi-ai/api/openai-completions"; + +// Guards the pnpm patch on @earendil-works/pi-ai (patches/@earendil-works__pi-ai@0.85.1.patch): +// DeepSeek-style endpoints accept a history where either every assistant message +// carries reasoning_content or none does, and reject a mix. A relayed model that +// is not in the catalogue has `reasoning: false`, so pi's per-message backfill +// never ran and a mixed history was sent as is (#223). + +const compat = { + supportsDeveloperRole: false, + supportsOpenAIGrammarTools: false, + requiresToolResultName: false, + requiresAssistantAfterToolResult: false, + requiresThinkingAsText: false, + requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "deepseek", + deferredToolsMode: "none", +} as never; + +function model(reasoning: boolean) { + return { + id: "deepseek-relay", + name: "deepseek-relay", + api: "openai-completions", + provider: "custom-relay", + baseUrl: "https://relay.example/v1", + reasoning, + input: ["text"], + contextWindow: 128_000, + maxTokens: 8_192, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: {}, + } as never; +} + +function assistant(content: unknown[]) { + return { + role: "assistant", + api: "openai-completions", + provider: "custom-relay", + model: "deepseek-relay", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1, + content, + }; +} + +const user = (text: string) => ({ role: "user", content: text, timestamp: 1 }); + +function assistantParams(messages: unknown[]) { + return convertMessages(model(false), { systemPrompt: "", messages, tools: [] } as never, compat) + .filter((message) => message.role === "assistant") + .map((message) => (message as { reasoning_content?: unknown }).reasoning_content); +} + +describe("reasoning_content backfill for non-catalogue DeepSeek models", () => { + it("fills an empty reasoning_content on assistant turns without thinking once any turn has it", () => { + const reasoning = assistantParams([ + user("first"), + assistant([ + { type: "thinking", thinking: "let me think", thinkingSignature: "reasoning_content" }, + { type: "text", text: "thought answer" }, + ]), + user("second"), + assistant([{ type: "text", text: "plain answer" }]), + ]); + expect(reasoning).toEqual(["let me think", ""]); + }); + + it("leaves a history without any thinking untouched", () => { + const reasoning = assistantParams([ + user("first"), + assistant([{ type: "text", text: "one" }]), + user("second"), + assistant([{ type: "text", text: "two" }]), + ]); + expect(reasoning).toEqual([undefined, undefined]); + }); +}); diff --git a/patches/@earendil-works__pi-ai@0.85.1.patch b/patches/@earendil-works__pi-ai@0.85.1.patch index 965300410..e371757e1 100644 --- a/patches/@earendil-works__pi-ai@0.85.1.patch +++ b/patches/@earendil-works__pi-ai@0.85.1.patch @@ -1,3 +1,30 @@ +diff --git a/dist/api/openai-completions.js b/dist/api/openai-completions.js +index 48464f2bf56b9369d5c202e4f02fc4f13d35b412..fd36bd0f3daab13023c0220bb44c475503ec0c28 100644 +--- a/dist/api/openai-completions.js ++++ b/dist/api/openai-completions.js +@@ -1141,6 +1141,22 @@ export function convertMessages(model, context, compat, options) { + } + lastRole = msg.role; + } ++ // DeepSeek-style endpoints reject a history in which only some assistant ++ // messages carry reasoning_content, while all-or-none is accepted. The ++ // per-message backfill above only runs for models flagged `reasoning`, ++ // which a relayed model outside the catalogue is not, even though its ++ // persisted turns still carry thinking. Whenever any assistant message ++ // ends up with reasoning_content, give the rest an empty one. ++ if (compat.requiresReasoningContentOnAssistantMessages && ++ params.some((m) => m.role === "assistant" && ++ typeof m.reasoning_content === "string" && ++ m.reasoning_content.length > 0)) { ++ for (const m of params) { ++ if (m.role === "assistant" && m.reasoning_content === undefined) { ++ m.reasoning_content = ""; ++ } ++ } ++ } + return params; + } + function convertTools(tools, compat) { diff --git a/dist/api/openai-responses-shared.js b/dist/api/openai-responses-shared.js index 43e463dbfd1e6cc437ebc680468036f47678de26..364c8c0426d12543c1b64daa8e6eaa22f35f6f60 100644 --- a/dist/api/openai-responses-shared.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c671481e8..374c026c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,7 +15,7 @@ overrides: vite@5: '>=6.4.3 <7' patchedDependencies: - '@earendil-works/pi-ai@0.85.1': d5f5bb308c045076374ad6aac19483a00c2c12258cf39d7b3f9c0ba1bf4d5503 + '@earendil-works/pi-ai@0.85.1': 67163dbaed7f644c6f55f54b068361a26e6c5abc319f97817aba0703d7b0da75 importers: @@ -33,7 +33,7 @@ importers: devDependencies: '@earendil-works/pi-ai': specifier: 0.85.1 - version: 0.85.1(patch_hash=d5f5bb308c045076374ad6aac19483a00c2c12258cf39d7b3f9c0ba1bf4d5503)(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3) + version: 0.85.1(patch_hash=67163dbaed7f644c6f55f54b068361a26e6c5abc319f97817aba0703d7b0da75)(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3) '@pi-desktop/agent-host': specifier: workspace:* version: link:../../packages/agent-host @@ -166,7 +166,7 @@ importers: version: 0.85.1(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3) '@earendil-works/pi-ai': specifier: 0.85.1 - version: 0.85.1(patch_hash=d5f5bb308c045076374ad6aac19483a00c2c12258cf39d7b3f9c0ba1bf4d5503)(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3) + version: 0.85.1(patch_hash=67163dbaed7f644c6f55f54b068361a26e6c5abc319f97817aba0703d7b0da75)(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3) '@pi-desktop/shared': specifier: workspace:* version: link:../shared @@ -4564,7 +4564,7 @@ snapshots: '@earendil-works/pi-agent-core@0.85.1(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3)': dependencies: '@earendil-works/chord': 0.85.1 - '@earendil-works/pi-ai': 0.85.1(patch_hash=d5f5bb308c045076374ad6aac19483a00c2c12258cf39d7b3f9c0ba1bf4d5503)(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.85.1(patch_hash=67163dbaed7f644c6f55f54b068361a26e6c5abc319f97817aba0703d7b0da75)(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3) '@earendil-works/pi-telemetry': 0.85.1 diff: 8.0.4 ignore: 7.0.5 @@ -4578,7 +4578,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.85.1(patch_hash=d5f5bb308c045076374ad6aac19483a00c2c12258cf39d7b3f9c0ba1bf4d5503)(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3)': + '@earendil-works/pi-ai@0.85.1(patch_hash=67163dbaed7f644c6f55f54b068361a26e6c5abc319f97817aba0703d7b0da75)(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.123.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -4602,7 +4602,7 @@ snapshots: dependencies: '@earendil-works/chord': 0.85.1 '@earendil-works/pi-agent-core': 0.85.1(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3) - '@earendil-works/pi-ai': 0.85.1(patch_hash=d5f5bb308c045076374ad6aac19483a00c2c12258cf39d7b3f9c0ba1bf4d5503)(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.85.1(patch_hash=67163dbaed7f644c6f55f54b068361a26e6c5abc319f97817aba0703d7b0da75)(supports-color@7.2.0)(ws@8.21.1)(zod@4.4.3) '@earendil-works/pi-tui': 0.85.1 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2