Skip to content

Commit be38691

Browse files
committed
fix: Address PR #206 review feedback for OpenAI thinking mode
- Fix afterEach env var leak: use delete instead of assigning undefined - Fix turn boundary detection: treat multimodal inputs (e.g. images) as new turns, not just text content - Extract buildOpenAIRequestBody() for testability, replace constant- assertion tests with real function output verification Signed-off-by: guunergooner <tongchao0923@gmail.com>
1 parent 2fb146d commit be38691

3 files changed

Lines changed: 146 additions & 80 deletions

File tree

src/services/api/openai/__tests__/thinking.test.ts

Lines changed: 78 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
2-
import { isOpenAIThinkingEnabled } from '../index.js'
2+
import { isOpenAIThinkingEnabled, buildOpenAIRequestBody } from '../index.js'
33

44
describe('isOpenAIThinkingEnabled', () => {
55
const originalEnv = {
@@ -12,8 +12,13 @@ describe('isOpenAIThinkingEnabled', () => {
1212
})
1313

1414
afterEach(() => {
15-
// Restore original env var
16-
process.env.OPENAI_ENABLE_THINKING = originalEnv.OPENAI_ENABLE_THINKING
15+
// Restore original env var — delete key if it was originally undefined
16+
// to avoid leaking the env key into subsequent tests
17+
if (originalEnv.OPENAI_ENABLE_THINKING === undefined) {
18+
delete process.env.OPENAI_ENABLE_THINKING
19+
} else {
20+
process.env.OPENAI_ENABLE_THINKING = originalEnv.OPENAI_ENABLE_THINKING
21+
}
1722
})
1823

1924
describe('OPENAI_ENABLE_THINKING env var', () => {
@@ -140,53 +145,82 @@ describe('isOpenAIThinkingEnabled', () => {
140145
})
141146
})
142147

143-
describe('thinking request parameters', () => {
144-
// Note: These tests verify the request body structure indirectly.
145-
// The actual API call is mocked in integration tests.
146-
// Here we document the expected parameter formats:
148+
describe('buildOpenAIRequestBody — thinking params', () => {
149+
const baseParams = {
150+
model: 'deepseek-reasoner',
151+
messages: [{ role: 'user', content: 'hello' }],
152+
tools: [] as any[],
153+
toolChoice: undefined as any,
154+
}
147155

148-
test('documents official DeepSeek API format: thinking: { type: "enabled" }', () => {
149-
// Official DeepSeek API expects:
150-
const officialFormat = {
151-
thinking: { type: 'enabled' },
152-
}
153-
expect(officialFormat.thinking.type).toBe('enabled')
156+
test('includes official DeepSeek API thinking format when enabled', () => {
157+
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: true })
158+
expect(body.thinking).toEqual({ type: 'enabled' })
154159
})
155160

156-
test('documents vLLM/self-hosted format: enable_thinking + chat_template_kwargs', () => {
157-
// Self-hosted DeepSeek-V3.2/vLLM expects:
158-
const vllmFormat = {
159-
enable_thinking: true,
160-
chat_template_kwargs: { thinking: true },
161-
}
162-
expect(vllmFormat.enable_thinking).toBe(true)
163-
expect(vllmFormat.chat_template_kwargs.thinking).toBe(true)
161+
test('includes vLLM/self-hosted thinking format when enabled', () => {
162+
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: true })
163+
expect(body.enable_thinking).toBe(true)
164+
expect(body.chat_template_kwargs).toEqual({ thinking: true })
164165
})
165166

166-
test('both formats are added simultaneously when thinking is enabled', () => {
167-
// The implementation adds both formats so each endpoint
168-
// can use the one it recognizes:
169-
const combinedFormat = {
170-
// Official DeepSeek API format
171-
thinking: { type: 'enabled' },
172-
// Self-hosted DeepSeek-V3.2/vLLM format
173-
enable_thinking: true,
174-
chat_template_kwargs: { thinking: true },
175-
}
176-
expect(combinedFormat.thinking.type).toBe('enabled')
177-
expect(combinedFormat.enable_thinking).toBe(true)
178-
expect(combinedFormat.chat_template_kwargs.thinking).toBe(true)
167+
test('includes both formats simultaneously when enabled', () => {
168+
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: true })
169+
expect(body.thinking).toEqual({ type: 'enabled' })
170+
expect(body.enable_thinking).toBe(true)
171+
expect(body.chat_template_kwargs.thinking).toBe(true)
179172
})
180173

181-
test('thinking params are NOT added when thinking is disabled', () => {
182-
// When thinking is disabled, none of these params should be present:
183-
const disabledFormat = {
184-
model: 'gpt-4o',
185-
messages: [],
186-
stream: true,
187-
}
188-
expect((disabledFormat as any).thinking).toBeUndefined()
189-
expect((disabledFormat as any).enable_thinking).toBeUndefined()
190-
expect((disabledFormat as any).chat_template_kwargs).toBeUndefined()
174+
test('does NOT include thinking params when disabled', () => {
175+
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false })
176+
expect(body.thinking).toBeUndefined()
177+
expect(body.enable_thinking).toBeUndefined()
178+
expect(body.chat_template_kwargs).toBeUndefined()
179+
})
180+
181+
test('always includes stream and stream_options', () => {
182+
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false })
183+
expect(body.stream).toBe(true)
184+
expect(body.stream_options).toEqual({ include_usage: true })
185+
})
186+
187+
test('includes temperature when thinking is off and override is set', () => {
188+
const body = buildOpenAIRequestBody({
189+
...baseParams,
190+
enableThinking: false,
191+
temperatureOverride: 0.7,
192+
})
193+
expect(body.temperature).toBe(0.7)
194+
})
195+
196+
test('excludes temperature when thinking is on even if override is set', () => {
197+
const body = buildOpenAIRequestBody({
198+
...baseParams,
199+
enableThinking: true,
200+
temperatureOverride: 0.7,
201+
})
202+
expect(body.temperature).toBeUndefined()
203+
})
204+
205+
test('excludes temperature when thinking is off and no override', () => {
206+
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false })
207+
expect(body.temperature).toBeUndefined()
208+
})
209+
210+
test('includes tools and tool_choice when tools are provided', () => {
211+
const body = buildOpenAIRequestBody({
212+
...baseParams,
213+
tools: [{ type: 'function', function: { name: 'test' } }],
214+
toolChoice: 'auto',
215+
enableThinking: false,
216+
})
217+
expect(body.tools).toHaveLength(1)
218+
expect(body.tool_choice).toBe('auto')
219+
})
220+
221+
test('excludes tools when empty', () => {
222+
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false })
223+
expect(body.tools).toBeUndefined()
224+
expect(body.tool_choice).toBeUndefined()
191225
})
192226
})

src/services/api/openai/convertMessages.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,20 @@ export function anthropicMessagesToOpenAI(
5959
}
6060
if (msg.type === 'user' && hasSeenAssistant) {
6161
const content = msg.message.content
62-
const hasText = typeof content === 'string'
62+
// A user message starts a new turn if it contains any non-tool_result content
63+
// (text, image, or other media). Tool results alone do NOT start a new turn
64+
// because they are continuations of the previous assistant tool call.
65+
const startsNewUserTurn = typeof content === 'string'
6366
? content.length > 0
6467
: Array.isArray(content) && content.some(
65-
(b: any) => typeof b === 'string' || b.type === 'text',
68+
(b: any) =>
69+
typeof b === 'string' ||
70+
(b &&
71+
typeof b === 'object' &&
72+
'type' in b &&
73+
b.type !== 'tool_result'),
6674
)
67-
if (hasText) {
75+
if (startsNewUserTurn) {
6876
turnBoundaries.add(i)
6977
}
7078
}

src/services/api/openai/index.ts

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,53 @@ export function isOpenAIThinkingEnabled(model: string): boolean {
6363
return modelLower.includes('deepseek-reasoner') || modelLower.includes('deepseek-v3.2')
6464
}
6565

66+
/**
67+
* Build the request body for OpenAI chat.completions.create().
68+
* Extracted for testability — the thinking mode params are injected here.
69+
*
70+
* DeepSeek thinking mode: inject thinking params via request body.
71+
* Two formats are added simultaneously to support different deployments:
72+
* - Official DeepSeek API: `thinking: { type: 'enabled' }`
73+
* - Self-hosted DeepSeek-V3.2: `enable_thinking: true` + `chat_template_kwargs: { thinking: true }`
74+
* OpenAI SDK passes unknown keys through to the HTTP body.
75+
* Each endpoint will use the format it recognizes and ignore the others.
76+
* @internal Exported for testing purposes only
77+
*/
78+
export function buildOpenAIRequestBody(params: {
79+
model: string
80+
messages: any[]
81+
tools: any[]
82+
toolChoice: any
83+
enableThinking: boolean
84+
temperatureOverride?: number
85+
}): Record<string, any> {
86+
const { model, messages, tools, toolChoice, enableThinking, temperatureOverride } = params
87+
return {
88+
model,
89+
messages,
90+
...(tools.length > 0 && {
91+
tools,
92+
...(toolChoice && { tool_choice: toolChoice }),
93+
}),
94+
stream: true,
95+
stream_options: { include_usage: true },
96+
// DeepSeek thinking mode: enable chain-of-thought output.
97+
// When active, temperature/top_p/presence_penalty/frequency_penalty are ignored by DeepSeek.
98+
...(enableThinking && {
99+
// Official DeepSeek API format
100+
thinking: { type: 'enabled' },
101+
// Self-hosted DeepSeek-V3.2 format
102+
enable_thinking: true,
103+
chat_template_kwargs: { thinking: true },
104+
}),
105+
// Only send temperature when thinking mode is off (DeepSeek ignores it anyway,
106+
// but other providers may respect it)
107+
...(!enableThinking && temperatureOverride !== undefined && {
108+
temperature: temperatureOverride,
109+
}),
110+
}
111+
}
112+
66113
/**
67114
* OpenAI-compatible query path. Converts Anthropic-format messages/tools to
68115
* OpenAI format, calls the OpenAI-compatible endpoint, and converts the
@@ -177,40 +224,17 @@ export async function* queryModelOpenAI(
177224
)
178225

179226
// 11. Call OpenAI API with streaming
180-
// DeepSeek thinking mode: inject thinking params via request body.
181-
// Two formats are added simultaneously to support different deployments:
182-
// - Official DeepSeek API: `thinking: { type: 'enabled' }`
183-
// - Self-hosted DeepSeek-V3.2: `enable_thinking: true` + `chat_template_kwargs: { thinking: true }`
184-
// OpenAI SDK passes unknown keys through to the HTTP body.
185-
// Each endpoint will use the format it recognizes and ignore the others.
227+
const requestBody = buildOpenAIRequestBody({
228+
model: openaiModel,
229+
messages: openaiMessages,
230+
tools: openaiTools,
231+
toolChoice: openaiToolChoice,
232+
enableThinking,
233+
temperatureOverride: options.temperatureOverride,
234+
})
186235
const stream = await client.chat.completions.create(
187-
{
188-
model: openaiModel,
189-
messages: openaiMessages,
190-
...(openaiTools.length > 0 && {
191-
tools: openaiTools,
192-
...(openaiToolChoice && { tool_choice: openaiToolChoice }),
193-
}),
194-
stream: true,
195-
stream_options: { include_usage: true },
196-
// DeepSeek thinking mode: enable chain-of-thought output.
197-
// When active, temperature/top_p/presence_penalty/frequency_penalty are ignored by DeepSeek.
198-
...(enableThinking && {
199-
// Official DeepSeek API format
200-
thinking: { type: 'enabled' },
201-
// Self-hosted DeepSeek-V3.2 format
202-
enable_thinking: true,
203-
chat_template_kwargs: { thinking: true },
204-
}),
205-
// Only send temperature when thinking mode is off (DeepSeek ignores it anyway,
206-
// but other providers may respect it)
207-
...(!enableThinking && options.temperatureOverride !== undefined && {
208-
temperature: options.temperatureOverride,
209-
}),
210-
},
211-
{
212-
signal,
213-
},
236+
requestBody,
237+
{ signal },
214238
)
215239

216240
// 12. Convert OpenAI stream to Anthropic events, then process into

0 commit comments

Comments
 (0)