From 02b91a91da8a012758e596d749628e94eccc8242 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq Date: Sat, 8 Aug 2026 21:54:20 +0300 Subject: [PATCH] fix(providers): detect rejected reasoning history Probe assistant reasoning compatibility after thinking support detection and disable history fields only when the provider rejects them. Closes #220 --- src/server/providers/auto-config.test.ts | 51 +++++++++++++++++- src/server/providers/auto-config.ts | 39 ++++++++++++++ .../components/shared/ProviderModal.test.tsx | 53 +++++++++++++++++++ web/src/components/shared/ProviderModal.tsx | 4 ++ 4 files changed, 146 insertions(+), 1 deletion(-) diff --git a/src/server/providers/auto-config.test.ts b/src/server/providers/auto-config.test.ts index 97273984..c762eea6 100644 --- a/src/server/providers/auto-config.test.ts +++ b/src/server/providers/auto-config.test.ts @@ -5,7 +5,8 @@ * winning combo for each backend based on actual observed behavior. */ -import { describe, it, expect } from 'vitest' +import { afterEach, describe, it, expect, vi } from 'vitest' +import { autoConfig } from './auto-config.js' /** * Simulated probe results based on real data collected from each backend. @@ -197,3 +198,51 @@ describe('Context window detection', () => { }) }) }) + +describe('Reasoning message compatibility', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('disables reasoning messages when the API rejects assistant reasoning', async () => { + const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { messages?: Array> } + if (body.messages?.some((message) => message['reasoning'] === 'probe')) { + return new Response(JSON.stringify({ error: 'reasoning is not allowed' }), { status: 422 }) + } + return new Response(JSON.stringify({ choices: [{ message: { content: 'hi' } }] }), { status: 200 }) + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await autoConfig({ + url: 'https://provider.example/v1', + backend: 'unknown', + models: [{ id: 'deepseek-v4-flash' }], + }) + + expect(result.models[0]?.sendReasoningInMessages).toBe(false) + expect( + fetchMock.mock.calls.some(([, init]) => { + const body = JSON.parse(String(init?.body)) as { messages?: Array> } + return body.messages?.some((message) => message['reasoning'] === 'probe') + }), + ).toBe(true) + }) + + it('leaves reasoning messages unchanged when probing cannot reach the API', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('network unavailable') + }), + ) + + const result = await autoConfig({ + url: 'https://provider.example/v1', + backend: 'unknown', + models: [{ id: 'deepseek-v4-flash' }], + }) + + expect(result.models[0]?.sendReasoningInMessages).toBeUndefined() + }) +}) diff --git a/src/server/providers/auto-config.ts b/src/server/providers/auto-config.ts index 65733dd4..c40d5606 100644 --- a/src/server/providers/auto-config.ts +++ b/src/server/providers/auto-config.ts @@ -17,6 +17,8 @@ export interface ModelProbeResult { supportsVision: boolean thinkingConfig: Record | null nonThinkingConfig: Record | null + /** Set to false when the provider rejects reasoning in assistant history. */ + sendReasoningInMessages?: boolean } export interface AutoConfigInput { @@ -281,6 +283,38 @@ async function probeCombos( return null } +async function probeReasoningInMessages( + baseUrl: string, + apiKey: string | undefined, + model: string, +): Promise { + const headers: Record = { 'Content-Type': 'application/json' } + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + + try { + const response = await fetch(`${ensureVersionPrefix(baseUrl)}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model, + messages: [ + { role: 'user', content: 'say hi in one word' }, + { role: 'assistant', content: 'hi', reasoning: 'probe' }, + { role: 'user', content: 'say hi in one word' }, + ], + max_tokens: 1, + }), + signal: AbortSignal.timeout(15_000), + }) + + return response.ok + } catch { + // A transport failure is inconclusive; do not disable a provider setting + // based on an unavailable endpoint. + return undefined + } +} + // ============================================================================ // Main entry point // ============================================================================ @@ -305,6 +339,10 @@ export async function autoConfig(input: AutoConfigInput): Promise { expect(savedData.authAdapter).toBeUndefined() expect(savedData.transportAdapter).toBeUndefined() }) + + it('disables reasoning messages when auto-config detects a rejected history field', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/api/providers/auto-config')) { + return new Response( + JSON.stringify({ + models: [ + { + id: 'test-model', + contextWindow: 200000, + supportsVision: false, + thinkingConfig: { reasoning_effort: 'high' }, + nonThinkingConfig: null, + sendReasoningInMessages: false, + }, + ], + }), + { status: 200 }, + ) + } + return new Response(JSON.stringify({ presets: [] }), { status: 200 }) + }), + ) + + await new Promise((resolve) => { + root.render( + void} + initialStep={2} + editProvider={makeEditProvider()} + editModelId="test-model" + />, + ) + setTimeout(resolve, 200) + }) + + const autoConfigButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Auto-config', + ) as HTMLButtonElement | undefined + autoConfigButton?.click() + await new Promise((resolve) => setTimeout(resolve, 50)) + + const saveButton = container.querySelector('[data-testid="provider-modal-save"]') as HTMLButtonElement | null + saveButton?.click() + + const savedData: ProviderFormData = onSaveMock.mock.calls[0]![0]! + expect(savedData.sendReasoningInMessages).toBe(false) + }) }) diff --git a/web/src/components/shared/ProviderModal.tsx b/web/src/components/shared/ProviderModal.tsx index 30b3102e..e95f5b0b 100644 --- a/web/src/components/shared/ProviderModal.tsx +++ b/web/src/components/shared/ProviderModal.tsx @@ -844,6 +844,7 @@ export function ProviderModal({ supportsVision: boolean thinkingConfig: Record | null nonThinkingConfig: Record | null + sendReasoningInMessages?: boolean }> } for (const m of data.models) { @@ -861,6 +862,9 @@ export function ProviderModal({ config.nonThinkingEnabled = true config.nonThinkingQueryParams = JSON.stringify(m.nonThinkingConfig) } + if (m.sendReasoningInMessages === false) { + setSendReasoningInMessages(false) + } updateModelConfig(m.id, config) setAutoConfigState((prev) => ({ ...prev,