Preserve DeepSeek reasoning history for OpenAI chat proxies

DeepSeek-compatible OpenAI Chat providers require assistant reasoning_content to be replayed on later tool-call turns when thinking mode is active. The Anthropic-to-OpenAI chat transform now keeps that replay behavior behind an explicit compatibility option, and the proxy only enables it for DeepSeek/OpenCode base URLs so generic OpenAI-compatible providers keep their previous request shape.

Constraint: DeepSeek thinking mode rejects follow-up requests when prior assistant reasoning_content is omitted

Rejected: Trigger compatibility by model name | would affect unrelated OpenAI-compatible providers using DeepSeek model ids

Confidence: high

Scope-risk: narrow

Directive: Do not broaden this compatibility gate without proving other OpenAI-compatible providers accept these non-standard fields

Tested: Fixed-port Web UI harness with copied DeepSeek OpenAI Chat provider reproduced the 400 and passed through local proxy after the fix

Tested: bun test src/server/__tests__/proxy-transform.test.ts

Tested: bun run check:server

Tested: git diff --check

Not-tested: Direct OpenCode Go subscription endpoint, unavailable locally
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 17:40:43 +08:00
parent dc0307f4c1
commit 63ef7b1a5b
4 changed files with 77 additions and 7 deletions

View File

@ -139,6 +139,18 @@ describe('anthropicToOpenaiChat', () => {
expect(anthropicToOpenaiChat(highReq).reasoning_effort).toBe('high')
})
test('passes explicit thinking toggle for DeepSeek-compatible chat proxies', () => {
const req: AnthropicRequest = {
model: 'deepseek-v4-flash',
max_tokens: 100,
messages: [{ role: 'user', content: 'Hi' }],
thinking: { type: 'disabled' },
}
expect(anthropicToOpenaiChat(req).thinking).toBeUndefined()
expect(anthropicToOpenaiChat(req, { passThinkingToggle: true }).thinking).toEqual({ type: 'disabled' })
})
test('assistant message with tool_use', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
@ -161,6 +173,32 @@ describe('anthropicToOpenaiChat', () => {
expect(msg.tool_calls![0].function.arguments).toBe('{"city":"NYC"}')
})
test('round-trips assistant thinking as reasoning_content for DeepSeek tool-call history', () => {
const req: AnthropicRequest = {
model: 'deepseek-v4-pro',
max_tokens: 100,
messages: [{
role: 'assistant',
content: [
{ type: 'thinking', thinking: 'Need the date first. ' },
{ type: 'thinking', thinking: 'Then call weather.' },
{ type: 'text', text: 'Let me check that.' },
{ type: 'tool_use', id: 'call_1', name: 'get_weather', input: { location: 'Hangzhou' } },
],
}],
}
const defaultResult = anthropicToOpenaiChat(req)
expect(defaultResult.messages[0].reasoning_content).toBeUndefined()
const result = anthropicToOpenaiChat(req, { roundTripReasoningContent: true })
const msg = result.messages[0]
expect(msg.role).toBe('assistant')
expect(msg.content).toBe('Let me check that.')
expect(msg.reasoning_content).toBe('Need the date first. Then call weather.')
expect(msg.tool_calls?.[0].id).toBe('call_1')
})
test('user message with tool_result', () => {
const req: AnthropicRequest = {
model: 'gpt-4',

View File

@ -109,7 +109,11 @@ async function handleOpenaiChat(
apiKey: string,
isStream: boolean,
): Promise<Response> {
const transformed = anthropicToOpenaiChat(body)
const deepSeekCompatible = shouldUseDeepSeekReasoningCompat(baseUrl)
const transformed = anthropicToOpenaiChat(body, {
roundTripReasoningContent: deepSeekCompatible,
passThinkingToggle: deepSeekCompatible,
})
const url = `${baseUrl}/v1/chat/completions`
const upstream = await fetch(url, {
@ -160,6 +164,13 @@ async function handleOpenaiChat(
return Response.json(anthropicResponse)
}
function shouldUseDeepSeekReasoningCompat(baseUrl: string): boolean {
return (
/(^|[./-])deepseek([./-]|$)/i.test(baseUrl) ||
/(^|[./-])opencode\.ai([:/]|$)/i.test(baseUrl)
)
}
async function handleOpenaiResponses(
body: AnthropicRequest,
baseUrl: string,

View File

@ -18,7 +18,10 @@ import type {
/**
* Convert Anthropic Messages request to OpenAI Chat Completions request.
*/
export function anthropicToOpenaiChat(body: AnthropicRequest): OpenAIChatRequest {
export function anthropicToOpenaiChat(
body: AnthropicRequest,
options: { roundTripReasoningContent?: boolean; passThinkingToggle?: boolean } = {},
): OpenAIChatRequest {
const messages: OpenAIChatMessage[] = []
// Convert system prompt
@ -33,7 +36,7 @@ export function anthropicToOpenaiChat(body: AnthropicRequest): OpenAIChatRequest
// Convert messages
for (const msg of body.messages) {
convertMessage(msg, messages)
convertMessage(msg, messages, options)
}
// Build request
@ -85,12 +88,19 @@ export function anthropicToOpenaiChat(body: AnthropicRequest): OpenAIChatRequest
} else if (body.thinking.type === 'enabled') {
result.reasoning_effort = 'high'
}
if (options.passThinkingToggle) {
result.thinking = { type: body.thinking.type }
}
}
return result
}
function convertMessage(msg: AnthropicMessage, output: OpenAIChatMessage[]): void {
function convertMessage(
msg: AnthropicMessage,
output: OpenAIChatMessage[],
options: { roundTripReasoningContent?: boolean },
): void {
const content = msg.content
// Simple string content
@ -108,7 +118,7 @@ function convertMessage(msg: AnthropicMessage, output: OpenAIChatMessage[]): voi
if (msg.role === 'user') {
convertUserMessage(content, output)
} else {
convertAssistantMessage(content, output)
convertAssistantMessage(content, output, options)
}
}
@ -147,13 +157,20 @@ function convertUserMessage(blocks: AnthropicContentBlock[], output: OpenAIChatM
}
}
function convertAssistantMessage(blocks: AnthropicContentBlock[], output: OpenAIChatMessage[]): void {
function convertAssistantMessage(
blocks: AnthropicContentBlock[],
output: OpenAIChatMessage[],
options: { roundTripReasoningContent?: boolean },
): void {
let textContent = ''
let reasoningContent = ''
const toolCalls: OpenAIToolCall[] = []
for (const block of blocks) {
if (block.type === 'text') {
textContent += block.text
} else if (block.type === 'thinking' && options.roundTripReasoningContent) {
reasoningContent += block.thinking
} else if (block.type === 'tool_use') {
toolCalls.push({
id: block.id,
@ -164,7 +181,6 @@ function convertAssistantMessage(blocks: AnthropicContentBlock[], output: OpenAI
},
})
}
// Skip thinking blocks — no OpenAI equivalent
}
const msg: OpenAIChatMessage = {
@ -175,6 +191,9 @@ function convertAssistantMessage(blocks: AnthropicContentBlock[], output: OpenAI
if (toolCalls.length > 0) {
msg.tool_calls = toolCalls
}
if (reasoningContent) {
msg.reasoning_content = reasoningContent
}
output.push(msg)
}

View File

@ -10,6 +10,7 @@ export type OpenAIChatMessage = {
role: 'system' | 'user' | 'assistant' | 'tool'
content?: string | OpenAIChatContentPart[] | null
name?: string
reasoning_content?: string
tool_calls?: OpenAIToolCall[]
tool_call_id?: string
}
@ -48,6 +49,7 @@ export type OpenAIChatRequest = {
tools?: OpenAITool[]
tool_choice?: unknown
reasoning_effort?: 'low' | 'medium' | 'high'
thinking?: { type: string }
}
export type OpenAIChatResponse = {