Skip to content
Closed
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
242 changes: 242 additions & 0 deletions src/services/api/openai/__tests__/responses.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { afterEach, describe, expect, test } from 'bun:test'
import type { ResponseStreamEvent } from 'openai/resources/responses/responses.mjs'
import {
adaptResponsesStreamToAnthropic,
buildOpenAIResponsesRequestBody,
resolveOpenAIWireAPI,
} from '../responses.js'

const originalWireAPI = process.env.OPENAI_WIRE_API

afterEach(() => {
if (originalWireAPI === undefined) {
delete process.env.OPENAI_WIRE_API
} else {
process.env.OPENAI_WIRE_API = originalWireAPI
}
})

async function collectAdaptedEvents(events: ResponseStreamEvent[]) {
async function* stream() {
for (const event of events) {
yield event
}
}

const result = []
for await (const event of adaptResponsesStreamToAnthropic(
stream() as any,
'test-model',
)) {
result.push(event)
}
return result
}

describe('resolveOpenAIWireAPI', () => {
test('defaults to chat completions', () => {
delete process.env.OPENAI_WIRE_API
expect(resolveOpenAIWireAPI()).toBe('chat_completions')
})

test('accepts responses env override', () => {
process.env.OPENAI_WIRE_API = 'responses'
expect(resolveOpenAIWireAPI()).toBe('responses')
})
})

describe('buildOpenAIResponsesRequestBody', () => {
test('converts messages, tools, and tool choice', () => {
const body = buildOpenAIResponsesRequestBody({
model: 'gpt-test',
messages: [
{
type: 'user',
message: { content: 'hello' },
},
{
type: 'assistant',
message: {
content: [
{
type: 'tool_use',
id: 'toolu_123',
name: 'bash',
input: { command: 'ls' },
},
],
},
},
{
type: 'user',
message: {
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_123',
content: 'ok',
},
{
type: 'text',
text: 'next',
},
],
},
},
] as any,
systemPrompt: ['system prompt'] as any,
tools: [
{
type: 'custom',
name: 'bash',
description: 'Run shell commands',
input_schema: {
type: 'object',
properties: {
command: { const: 'ls' },
},
},
strict: true,
},
] as any,
toolChoice: { type: 'tool', name: 'bash' },
enableThinking: false,
maxTokens: 4096,
temperatureOverride: 0.2,
})

expect(body.instructions).toBe('system prompt')
expect(body.max_output_tokens).toBe(4096)
expect(body.tool_choice).toEqual({ type: 'function', name: 'bash' })
expect(body.tools).toEqual([
{
type: 'function',
name: 'bash',
description: 'Run shell commands',
parameters: {
type: 'object',
properties: {
command: { enum: ['ls'] },
},
},
strict: true,
},
])
expect(body.input).toEqual([
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello' }],
},
{
type: 'function_call',
call_id: 'toolu_123',
name: 'bash',
arguments: '{"command":"ls"}',
},
{
type: 'function_call_output',
call_id: 'toolu_123',
output: 'ok',
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'next' }],
},
])
})
})

describe('adaptResponsesStreamToAnthropic', () => {
test('maps streamed function calls and terminal usage', async () => {
const events = await collectAdaptedEvents([
{
type: 'response.created',
sequence_number: 1,
response: {
id: 'resp_1',
object: 'response',
created_at: 0,
model: 'test-model',
output: [],
output_text: '',
tools: [],
tool_choice: 'auto',
parallel_tool_calls: false,
temperature: null,
top_p: null,
error: null,
incomplete_details: null,
instructions: null,
metadata: null,
usage: null,
},
} as any,
{
type: 'response.output_item.added',
sequence_number: 2,
output_index: 0,
item: {
type: 'function_call',
id: 'fc_1',
call_id: 'toolu_123',
name: 'bash',
arguments: '',
status: 'in_progress',
},
} as any,
{
type: 'response.function_call_arguments.delta',
sequence_number: 3,
output_index: 0,
item_id: 'fc_1',
delta: '{"command":"ls"}',
} as any,
{
type: 'response.completed',
sequence_number: 4,
response: {
usage: {
input_tokens: 11,
output_tokens: 7,
total_tokens: 18,
input_tokens_details: { cached_tokens: 2 },
output_tokens_details: { reasoning_tokens: 0 },
},
},
} as any,
])

expect(events).toEqual([
expect.objectContaining({ type: 'message_start' }),
expect.objectContaining({
type: 'content_block_start',
content_block: expect.objectContaining({
type: 'tool_use',
id: 'toolu_123',
name: 'bash',
}),
}),
expect.objectContaining({
type: 'content_block_delta',
delta: {
type: 'input_json_delta',
partial_json: '{"command":"ls"}',
},
}),
expect.objectContaining({ type: 'content_block_stop' }),
expect.objectContaining({
type: 'message_delta',
delta: { stop_reason: 'tool_use', stop_sequence: null },
usage: {
input_tokens: 11,
output_tokens: 7,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 2,
},
}),
expect.objectContaining({ type: 'message_stop' }),
])
})
})
53 changes: 37 additions & 16 deletions src/services/api/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import { logForDebugging } from '../../../utils/debug.js'
import { addToTotalSessionCost } from '../../../cost-tracker.js'
import { calculateUSDCost } from '../../../utils/modelCost.js'
import { isOpenAIThinkingEnabled, resolveOpenAIMaxTokens, buildOpenAIRequestBody } from './requestBody.js'
import {
adaptResponsesStreamToAnthropic,
buildOpenAIResponsesRequestBody,
resolveOpenAIWireAPI,
} from './responses.js'
import { recordLLMObservation } from '../../../services/langfuse/tracing.js'
import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../../../services/langfuse/convert.js'
export { isOpenAIThinkingEnabled, resolveOpenAIMaxTokens, buildOpenAIRequestBody }
Expand Down Expand Up @@ -273,29 +278,45 @@ export async function* queryModelOpenAI(
source: options.querySource,
})

const wireAPI = resolveOpenAIWireAPI()
logForDebugging(
`[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}, thinking=${enableThinking}`,
`[OpenAI] Calling model=${openaiModel}, wire_api=${wireAPI}, messages=${openaiMessages.length}, tools=${openaiTools.length}, thinking=${enableThinking}`,
)

// 12. Call OpenAI API with streaming
const requestBody = buildOpenAIRequestBody({
model: openaiModel,
messages: openaiMessages,
tools: openaiTools,
toolChoice: openaiToolChoice,
enableThinking,
maxTokens,
temperatureOverride: options.temperatureOverride,
})
const stream = await client.chat.completions.create(
requestBody,
{ signal },
)
let adaptedStream: AsyncIterable<any>
if (wireAPI === 'responses') {
const requestBody = buildOpenAIResponsesRequestBody({
model: openaiModel,
messages: messagesForAPI,
systemPrompt,
tools: standardTools,
toolChoice: options.toolChoice,
enableThinking,
maxTokens,
temperatureOverride: options.temperatureOverride,
})
const stream = await client.responses.create(requestBody, { signal })
adaptedStream = adaptResponsesStreamToAnthropic(stream, openaiModel)
Comment on lines 286 to +300
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the return type of normalizeMessagesForAPI to validate the type-mismatch claim.
fd -t f 'messages.ts' src/utils | xargs rg -n -A6 'export function normalizeMessagesForAPI'

Repository: claude-code-best/claude-code

Length of output: 375


🏁 Script executed:

#!/bin/bash
# Read the file to see actual types and variable assignments
sed -n '160,170p' src/services/api/openai/index.ts | cat -n
sed -n '220,240p' src/services/api/openai/index.ts | cat -n
sed -n '286,310p' src/services/api/openai/index.ts | cat -n

Repository: claude-code-best/claude-code

Length of output: 2492


🏁 Script executed:

#!/bin/bash
# Find buildOpenAIResponsesRequestBody definition to see what type it expects
rg -n 'export function buildOpenAIResponsesRequestBody|function buildOpenAIResponsesRequestBody' src/services/api/openai/ -A 10

Repository: claude-code-best/claude-code

Length of output: 843


🏁 Script executed:

#!/bin/bash
# Check what buildOpenAIResponsesRequestBody does with messages internally
sed -n '107,150p' src/services/api/openai/responses.ts | cat -n

Repository: claude-code-best/claude-code

Length of output: 1698


🏁 Script executed:

#!/bin/bash
# Check prependDeferredToolListIfNeeded signature to validate the proposed fix
rg -n 'export function prependDeferredToolListIfNeeded|function prependDeferredToolListIfNeeded' src/ -A 5

Repository: claude-code-best/claude-code

Length of output: 494


Responses branch omits the deferred-tools announcement, breaking tool search.

The responses branch passes messagesForAPI (line 291) while the chat-completions branch uses messagesWithDeferredToolList (derived from line 226-231). This means the responses path skips the <available-deferred-tools>…</available-deferred-tools> user message that ToolSearchTool relies on (per prependDeferredToolListIfNeeded's docstring — OpenAI-compatible endpoints can't consume Anthropic's defer_loading/tool_reference payloads). Tool search will be broken for users on the Responses wire API.

Both branches accept the same message type, so the fix is straightforward:

🐛 Proposed fix
     if (wireAPI === 'responses') {
       const requestBody = buildOpenAIResponsesRequestBody({
         model: openaiModel,
-        messages: messagesForAPI,
+        messages: messagesWithDeferredToolList,
         systemPrompt,
         tools: standardTools,
         toolChoice: options.toolChoice,
         enableThinking,
         maxTokens,
         temperatureOverride: options.temperatureOverride,
       })

Worth adding a tool-search test case for the responses path to lock this in.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 12. Call OpenAI API with streaming
const requestBody = buildOpenAIRequestBody({
model: openaiModel,
messages: openaiMessages,
tools: openaiTools,
toolChoice: openaiToolChoice,
enableThinking,
maxTokens,
temperatureOverride: options.temperatureOverride,
})
const stream = await client.chat.completions.create(
requestBody,
{ signal },
)
let adaptedStream: AsyncIterable<any>
if (wireAPI === 'responses') {
const requestBody = buildOpenAIResponsesRequestBody({
model: openaiModel,
messages: messagesForAPI,
systemPrompt,
tools: standardTools,
toolChoice: options.toolChoice,
enableThinking,
maxTokens,
temperatureOverride: options.temperatureOverride,
})
const stream = await client.responses.create(requestBody, { signal })
adaptedStream = adaptResponsesStreamToAnthropic(stream, openaiModel)
// 12. Call OpenAI API with streaming
let adaptedStream: AsyncIterable<any>
if (wireAPI === 'responses') {
const requestBody = buildOpenAIResponsesRequestBody({
model: openaiModel,
messages: messagesWithDeferredToolList,
systemPrompt,
tools: standardTools,
toolChoice: options.toolChoice,
enableThinking,
maxTokens,
temperatureOverride: options.temperatureOverride,
})
const stream = await client.responses.create(requestBody, { signal })
adaptedStream = adaptResponsesStreamToAnthropic(stream, openaiModel)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/api/openai/index.ts` around lines 286 - 300, The Responses
branch is sending messagesForAPI and thus omitting the deferred-tools
announcement that ToolSearchTool needs; update the responses path to use the
same messagesWithDeferredToolList that the chat-completions branch uses (i.e.,
pass messagesWithDeferredToolList into buildOpenAIResponsesRequestBody) so
prependDeferredToolListIfNeeded is honored; verify usage points including
buildOpenAIResponsesRequestBody, client.responses.create, and
adaptResponsesStreamToAnthropic to ensure the message variable change is
propagated.

} else {
const requestBody = buildOpenAIRequestBody({
model: openaiModel,
messages: openaiMessages,
tools: openaiTools,
toolChoice: openaiToolChoice,
enableThinking,
maxTokens,
temperatureOverride: options.temperatureOverride,
})
const stream = await client.chat.completions.create(
requestBody,
{ signal },
)
adaptedStream = adaptOpenAIStreamToAnthropic(stream, openaiModel)
}

// 12. Convert OpenAI stream to Anthropic events, then process into
// AssistantMessage + StreamEvent (matching the Anthropic path behavior)
const adaptedStream = adaptOpenAIStreamToAnthropic(stream, openaiModel)

// Accumulate content blocks and usage, same as the Anthropic path in claude.ts
const contentBlocks: Record<number, any> = {}
const collectedMessages: AssistantMessage[] = []
Expand Down
Loading