diff --git a/patches/@mariozechner+pi-ai+0.60.0.patch b/patches/@mariozechner+pi-ai+0.60.0.patch index e09cb9b0a..87f66b5a5 100644 --- a/patches/@mariozechner+pi-ai+0.60.0.patch +++ b/patches/@mariozechner+pi-ai+0.60.0.patch @@ -1,8 +1,25 @@ diff --git a/node_modules/@mariozechner/pi-ai/dist/providers/openai-completions.js b/node_modules/@mariozechner/pi-ai/dist/providers/openai-completions.js -index 29ae8cf..6d9ea61 100644 +index 29ae8cf..7204589 100644 --- a/node_modules/@mariozechner/pi-ai/dist/providers/openai-completions.js +++ b/node_modules/@mariozechner/pi-ai/dist/providers/openai-completions.js -@@ -474,13 +474,32 @@ export function convertMessages(model, context, compat) { +@@ -446,9 +446,15 @@ export function convertMessages(model, context, compat) { + : content; + if (filteredContent.length === 0) + continue; ++ // Flatten text-only content to a plain string for maximum endpoint ++ // compatibility. Ollama and some OpenAI-compatible servers reject the ++ // content-block array form for plain text with "invalid message format". ++ const isTextOnly = filteredContent.every((c) => c.type === "text"); + params.push({ + role: "user", +- content: filteredContent, ++ content: isTextOnly ++ ? filteredContent.map((c) => c.text).join("") ++ : filteredContent, + }); + } + } +@@ -474,13 +480,32 @@ export function convertMessages(model, context, compat) { // Filter out empty thinking blocks to avoid API validation errors const nonEmptyThinkingBlocks = thinkingBlocks.filter((b) => b.thinking && b.thinking.trim().length > 0); if (nonEmptyThinkingBlocks.length > 0) { @@ -37,7 +54,7 @@ index 29ae8cf..6d9ea61 100644 else { assistantMsg.content = [{ type: "text", text: thinkingText }]; } -@@ -658,6 +677,7 @@ function mapStopReason(reason) { +@@ -658,6 +683,7 @@ function mapStopReason(reason) { function detectCompat(model) { const provider = model.provider; const baseUrl = model.baseUrl; @@ -45,7 +62,7 @@ index 29ae8cf..6d9ea61 100644 const isZai = provider === "zai" || baseUrl.includes("api.z.ai"); const isNonStandard = provider === "cerebras" || baseUrl.includes("cerebras.ai") || -@@ -690,6 +710,7 @@ function detectCompat(model) { +@@ -690,6 +716,7 @@ function detectCompat(model) { requiresToolResultName: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, @@ -53,7 +70,7 @@ index 29ae8cf..6d9ea61 100644 thinkingFormat: isZai ? "zai" : "openai", openRouterRouting: {}, vercelGatewayRouting: {}, -@@ -714,6 +735,7 @@ function getCompat(model) { +@@ -714,6 +741,7 @@ function getCompat(model) { requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName, requiresAssistantAfterToolResult: model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult, requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, diff --git a/src/main/claude/pi-model-resolution.ts b/src/main/claude/pi-model-resolution.ts index 00c7ed003..a9bca2fe4 100644 --- a/src/main/claude/pi-model-resolution.ts +++ b/src/main/claude/pi-model-resolution.ts @@ -328,15 +328,20 @@ export function applyPiModelRuntimeOverrides( } as typeof nextModel; } - // DeepSeek V4 models on custom/relay endpoints need thinking blocks in content[] array. + // DeepSeek V4 models on some custom/relay endpoints (and local Ollama) need thinking + // blocks in the content[] array. Ollama CLOUD (ollama.com), however, rejects that + // non-standard {type:"thinking"} content block with "invalid message format". Local + // Ollama (loopback :11434) is a different service and keeps the existing behavior. if (nextModel.api === 'openai-completions' && DEEPSEEK_V4_MODEL_PATTERN.test(nextModel.id)) { const currentCompat = (nextModel.compat || {}) as Record; - if (!currentCompat.requiresThinkingInContent) { + const endpoint = `${options.customBaseUrl || ''} ${nextModel.baseUrl || ''}`.toLowerCase(); + const isOllamaCloud = endpoint.includes('ollama.com'); + if (currentCompat.requiresThinkingInContent === undefined) { nextModel = { ...nextModel, compat: { ...currentCompat, - requiresThinkingInContent: true, + requiresThinkingInContent: !isOllamaCloud, }, } as typeof nextModel; } diff --git a/src/tests/claude/pi-model-resolution.test.ts b/src/tests/claude/pi-model-resolution.test.ts index d0e6b6c90..602937d5d 100644 --- a/src/tests/claude/pi-model-resolution.test.ts +++ b/src/tests/claude/pi-model-resolution.test.ts @@ -18,6 +18,19 @@ const openAIResponsesModel = { maxTokens: 16384, } as Model; +const deepseekV4CompletionsModel = { + id: 'deepseek-v4-pro', + name: 'deepseek-v4-pro', + api: 'openai-completions', + provider: 'ollama', + baseUrl: 'https://ollama.com/v1', + reasoning: true, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, +} as Model; + describe('pi model runtime overrides', () => { it('keeps OpenAI Responses for custom OpenAI configs that target official OpenAI', () => { const model = resolvePiRegistryModel('openai/gpt-5.4', { @@ -46,4 +59,34 @@ describe('pi model runtime overrides', () => { supportsStore: false, }); }); + + it('disables thinking-in-content for DeepSeek V4 on Ollama Cloud (ollama.com)', () => { + // Ollama Cloud rejects the non-standard {type:"thinking"} content block with + // "invalid message format", so thinking must not be serialized into content[]. + const model = applyPiModelRuntimeOverrides(deepseekV4CompletionsModel, { + configProvider: 'ollama', + rawProvider: 'ollama', + customBaseUrl: 'https://ollama.com/v1', + }); + + expect(model.compat).toMatchObject({ requiresThinkingInContent: false }); + }); + + it('keeps thinking-in-content for DeepSeek V4 on other custom/relay endpoints', () => { + const model = applyPiModelRuntimeOverrides( + { + ...deepseekV4CompletionsModel, + provider: 'custom', + baseUrl: 'https://relay.example.test/v1', + } as Model, + { + configProvider: 'custom', + rawProvider: 'custom', + customProtocol: 'openai', + customBaseUrl: 'https://relay.example.test/v1', + } + ); + + expect(model.compat).toMatchObject({ requiresThinkingInContent: true }); + }); });