Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion src/server/providers/auto-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Record<string, unknown>> }
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<Record<string, unknown>> }
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()
})
})
39 changes: 39 additions & 0 deletions src/server/providers/auto-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface ModelProbeResult {
supportsVision: boolean
thinkingConfig: Record<string, unknown> | null
nonThinkingConfig: Record<string, unknown> | null
/** Set to false when the provider rejects reasoning in assistant history. */
sendReasoningInMessages?: boolean
}

export interface AutoConfigInput {
Expand Down Expand Up @@ -281,6 +283,38 @@ async function probeCombos(
return null
}

async function probeReasoningInMessages(
baseUrl: string,
apiKey: string | undefined,
model: string,
): Promise<boolean | undefined> {
const headers: Record<string, string> = { '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
// ============================================================================
Expand All @@ -305,13 +339,18 @@ export async function autoConfig(input: AutoConfigInput): Promise<AutoConfigOutp
probeCombos(baseUrl, apiKey, model.id, NON_THINKING_COMBOS),
])

const sendReasoningInMessages = thinkingConfig
? await probeReasoningInMessages(baseUrl, apiKey, model.id)
: undefined

results.push({
id: model.id,
contextWindow,
contextSource,
supportsVision,
thinkingConfig,
nonThinkingConfig,
...(sendReasoningInMessages !== undefined ? { sendReasoningInMessages } : {}),
})
}

Expand Down
53 changes: 53 additions & 0 deletions web/src/components/shared/ProviderModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,4 +522,57 @@ describe('ProviderModal - thinkingLevel persistence', () => {
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<void>((resolve) => {
root.render(
<ProviderModal
isOpen={true}
onClose={vi.fn()}
onSave={onSaveMock as (provider: ProviderFormData) => 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)
})
})
4 changes: 4 additions & 0 deletions web/src/components/shared/ProviderModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,7 @@ export function ProviderModal({
supportsVision: boolean
thinkingConfig: Record<string, unknown> | null
nonThinkingConfig: Record<string, unknown> | null
sendReasoningInMessages?: boolean
}>
}
for (const m of data.models) {
Expand All @@ -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,
Expand Down
Loading