Skip to content

Commit d0c92ea

Browse files
committed
refactor: use OpenAI SDK for codex-mini-latest responses endpoint
- Replace fetch-based implementation with OpenAI SDK client.responses methods - Update createResponsesApiRequest to use client.responses.create() and client.responses.stream() - Simplify stream handling by using SDK async iterator instead of manual SSE parsing - Update tests to mock SDK responses methods instead of global fetch - Maintain same functionality while leveraging official SDK support for v1/responses endpoint Addresses feedback from @daniel-lxs to use OpenAI SDK since it now supports the responses endpoint
1 parent fea2e07 commit d0c92ea

File tree

2 files changed

+88
-168
lines changed

2 files changed

+88
-168
lines changed

src/api/providers/__tests__/openai-native.spec.ts

Lines changed: 34 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@ import { ApiHandlerOptions } from "../../../shared/api"
77

88
// Mock OpenAI client
99
const mockCreate = vitest.fn()
10-
const mockFetch = vitest.fn()
11-
12-
// Mock global fetch
13-
global.fetch = mockFetch as any
10+
const mockResponsesCreate = vitest.fn()
11+
const mockResponsesStream = vitest.fn()
1412

1513
vitest.mock("openai", () => {
1614
return {
@@ -66,6 +64,26 @@ vitest.mock("openai", () => {
6664
}),
6765
},
6866
},
67+
responses: {
68+
create: mockResponsesCreate.mockImplementation(async () => ({
69+
output_text: "Test response",
70+
})),
71+
stream: mockResponsesStream.mockImplementation(async () => ({
72+
[Symbol.asyncIterator]: async function* () {
73+
yield {
74+
type: "response.output_text.delta",
75+
delta: "Hello",
76+
}
77+
yield {
78+
type: "response.output_text.delta",
79+
delta: " world",
80+
}
81+
yield {
82+
type: "response.completed",
83+
}
84+
},
85+
})),
86+
},
6987
})),
7088
}
7189
})
@@ -88,7 +106,8 @@ describe("OpenAiNativeHandler", () => {
88106
}
89107
handler = new OpenAiNativeHandler(mockOptions)
90108
mockCreate.mockClear()
91-
mockFetch.mockClear()
109+
mockResponsesCreate.mockClear()
110+
mockResponsesStream.mockClear()
92111
})
93112

94113
describe("constructor", () => {
@@ -455,47 +474,16 @@ describe("OpenAiNativeHandler", () => {
455474
})
456475

457476
it("should handle streaming responses via v1/responses", async () => {
458-
const mockStreamData = [
459-
'data: {"type": "response.output_text.delta", "delta": "Hello"}\n',
460-
'data: {"type": "response.output_text.delta", "delta": " world"}\n',
461-
'data: {"type": "response.completed"}\n',
462-
"data: [DONE]\n",
463-
]
464-
465-
const encoder = new TextEncoder()
466-
const stream = new ReadableStream({
467-
start(controller) {
468-
for (const data of mockStreamData) {
469-
controller.enqueue(encoder.encode(data))
470-
}
471-
controller.close()
472-
},
473-
})
474-
475-
mockFetch.mockResolvedValueOnce({
476-
ok: true,
477-
status: 200,
478-
body: stream,
479-
})
480-
481477
const responseStream = handler.createMessage(systemPrompt, messages)
482478
const chunks: any[] = []
483479
for await (const chunk of responseStream) {
484480
chunks.push(chunk)
485481
}
486482

487-
expect(mockFetch).toHaveBeenCalledWith("https://api.openai.com/v1/responses", {
488-
method: "POST",
489-
headers: {
490-
"Content-Type": "application/json",
491-
Authorization: "Bearer test-api-key",
492-
},
493-
body: JSON.stringify({
494-
model: "codex-mini-latest",
495-
instructions: systemPrompt,
496-
input: "Hello!",
497-
stream: true,
498-
}),
483+
expect(mockResponsesStream).toHaveBeenCalledWith({
484+
model: "codex-mini-latest",
485+
instructions: systemPrompt,
486+
input: "Hello!",
499487
})
500488

501489
const textChunks = chunks.filter((chunk) => chunk.type === "text")
@@ -505,47 +493,26 @@ describe("OpenAiNativeHandler", () => {
505493
})
506494

507495
it("should handle non-streaming completion via v1/responses", async () => {
508-
mockFetch.mockResolvedValueOnce({
509-
ok: true,
510-
status: 200,
511-
json: async () => ({ output_text: "Test response" }),
512-
})
513-
514496
const result = await handler.completePrompt("Test prompt")
515497

516-
expect(mockFetch).toHaveBeenCalledWith("https://api.openai.com/v1/responses", {
517-
method: "POST",
518-
headers: {
519-
"Content-Type": "application/json",
520-
Authorization: "Bearer test-api-key",
521-
},
522-
body: JSON.stringify({
523-
model: "codex-mini-latest",
524-
instructions: "Complete the following prompt:",
525-
input: "Test prompt",
526-
stream: false,
527-
}),
498+
expect(mockResponsesCreate).toHaveBeenCalledWith({
499+
model: "codex-mini-latest",
500+
instructions: "Complete the following prompt:",
501+
input: "Test prompt",
528502
})
529503

530504
expect(result).toBe("Test response")
531505
})
532506

533507
it("should handle API errors", async () => {
534-
mockFetch.mockResolvedValueOnce({
535-
ok: false,
536-
status: 404,
537-
statusText: "Not Found",
538-
text: async () => "This model is only supported in v1/responses",
539-
})
508+
mockResponsesStream.mockRejectedValueOnce(new Error("API Error"))
540509

541510
const stream = handler.createMessage(systemPrompt, messages)
542511
await expect(async () => {
543512
for await (const _chunk of stream) {
544513
// Should not reach here
545514
}
546-
}).rejects.toThrow(
547-
"OpenAI Responses API error: 404 Not Found - This model is only supported in v1/responses",
548-
)
515+
}).rejects.toThrow("OpenAI Responses API error: API Error")
549516
})
550517
})
551518

src/api/providers/openai-native.ts

Lines changed: 54 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -126,47 +126,30 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
126126
}
127127

128128
/**
129-
* Makes a request to the OpenAI Responses API endpoint
129+
* Makes a request to the OpenAI Responses API endpoint using the OpenAI SDK
130130
* Used by codex-mini-latest model which requires the v1/responses endpoint
131131
*/
132-
private async makeResponsesApiRequest(
132+
private async createResponsesApiRequest(
133133
modelId: string,
134134
instructions: string,
135135
input: string,
136136
stream: boolean = true,
137-
): Promise<Response> {
138-
// Note: Using fetch() instead of OpenAI client because the OpenAI SDK v5.0.0
139-
// does not support the v1/responses endpoint used by codex-mini-latest model.
140-
// This is a special endpoint that requires a different request/response format.
141-
const apiKey = this.options.openAiNativeApiKey ?? "not-provided"
142-
const baseURL = this.options.openAiNativeBaseUrl ?? "https://api.openai.com/v1"
143-
137+
) {
144138
try {
145-
const response = await fetch(`${baseURL}/responses`, {
146-
method: "POST",
147-
headers: {
148-
"Content-Type": "application/json",
149-
Authorization: `Bearer ${apiKey}`,
150-
},
151-
body: JSON.stringify({
139+
if (stream) {
140+
return await this.client.responses.stream({
152141
model: modelId,
153142
instructions: instructions,
154143
input: input,
155-
stream: stream,
156-
}),
157-
})
158-
159-
if (!response.ok) {
160-
const errorText = await response.text()
161-
throw new Error(`OpenAI Responses API error: ${response.status} ${response.statusText} - ${errorText}`)
144+
})
145+
} else {
146+
return await this.client.responses.create({
147+
model: modelId,
148+
instructions: instructions,
149+
input: input,
150+
})
162151
}
163-
164-
return response
165152
} catch (error) {
166-
// Handle network failures and other errors
167-
if (error instanceof TypeError && error.message.includes("fetch")) {
168-
throw new Error(`Network error while calling OpenAI Responses API: ${error.message}`)
169-
}
170153
if (error instanceof Error) {
171154
throw new Error(`OpenAI Responses API error: ${error.message}`)
172155
}
@@ -182,9 +165,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
182165
// Convert messages to a single input string
183166
const input = this.convertMessagesToInput(messages)
184167

185-
// Make API call using shared helper
186-
const response = await this.makeResponsesApiRequest(model.id, systemPrompt, input, true)
187-
yield* this.handleResponsesStreamResponse(response.body, model, systemPrompt, input)
168+
// Make API call using OpenAI SDK
169+
const stream = await this.createResponsesApiRequest(model.id, systemPrompt, input, true)
170+
yield* this.handleResponsesSDKStreamResponse(stream, model, systemPrompt, input)
188171
}
189172

190173
private convertMessagesToInput(messages: Anthropic.Messages.MessageParam[]): string {
@@ -206,81 +189,46 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
206189
.join("\n\n")
207190
}
208191

209-
private async *handleResponsesStreamResponse(
210-
stream: ReadableStream<Uint8Array> | null,
192+
private async *handleResponsesSDKStreamResponse(
193+
stream: any, // OpenAI SDK stream type
211194
model: OpenAiNativeModel,
212195
systemPrompt: string,
213196
userInput: string,
214197
): ApiStream {
215-
if (!stream) {
216-
throw new Error("No response stream available")
217-
}
218-
219198
let totalText = ""
220-
const reader = stream.getReader()
221-
const decoder = new TextDecoder()
222-
let buffer = ""
223199

224200
try {
225-
while (true) {
226-
const { done, value } = await reader.read()
227-
if (done) break
228-
229-
buffer += decoder.decode(value, { stream: true })
230-
const lines = buffer.split("\n")
231-
buffer = lines.pop() || ""
232-
233-
for (const line of lines) {
234-
if (line.trim() === "") continue
235-
if (line.startsWith("data: ")) {
236-
const data = line.slice(6)
237-
if (data === "[DONE]") continue
238-
239-
try {
240-
const event = JSON.parse(data)
241-
// Handle different event types from responses API
242-
if (event.type === "response.output_text.delta") {
243-
yield {
244-
type: "text",
245-
text: event.delta,
246-
}
247-
totalText += event.delta
248-
} else if (event.type === "response.completed") {
249-
// Calculate usage based on text length (approximate)
250-
// Estimate tokens: ~1 token per 4 characters
251-
const promptTokens = Math.ceil((systemPrompt.length + userInput.length) / 4)
252-
const completionTokens = Math.ceil(totalText.length / 4)
253-
yield* this.yieldUsage(model.info, {
254-
prompt_tokens: promptTokens,
255-
completion_tokens: completionTokens,
256-
total_tokens: promptTokens + completionTokens,
257-
})
258-
} else if (event.type === "response.error") {
259-
// Handle error events from the API
260-
throw new Error(
261-
`OpenAI Responses API stream error: ${event.error?.message || "Unknown error"}`,
262-
)
263-
} else {
264-
// Log unknown event types for debugging and future compatibility
265-
console.debug(
266-
`OpenAI Responses API: Unknown event type '${event.type}' received`,
267-
event,
268-
)
269-
}
270-
} catch (e) {
271-
// Only skip if it's a JSON parsing error
272-
if (e instanceof SyntaxError) {
273-
console.debug("OpenAI Responses API: Failed to parse SSE data", data)
274-
} else {
275-
// Re-throw other errors (like API errors)
276-
throw e
277-
}
278-
}
201+
for await (const chunk of stream) {
202+
// Handle different event types from responses API
203+
if (chunk.type === "response.output_text.delta") {
204+
yield {
205+
type: "text",
206+
text: chunk.delta,
279207
}
208+
totalText += chunk.delta
209+
} else if (chunk.type === "response.completed") {
210+
// Calculate usage based on text length (approximate)
211+
// Estimate tokens: ~1 token per 4 characters
212+
const promptTokens = Math.ceil((systemPrompt.length + userInput.length) / 4)
213+
const completionTokens = Math.ceil(totalText.length / 4)
214+
yield* this.yieldUsage(model.info, {
215+
prompt_tokens: promptTokens,
216+
completion_tokens: completionTokens,
217+
total_tokens: promptTokens + completionTokens,
218+
})
219+
} else if (chunk.type === "response.error") {
220+
// Handle error events from the API
221+
throw new Error(`OpenAI Responses API stream error: ${chunk.error?.message || "Unknown error"}`)
222+
} else {
223+
// Log unknown event types for debugging and future compatibility
224+
console.debug(`OpenAI Responses API: Unknown event type '${chunk.type}' received`, chunk)
280225
}
281226
}
282-
} finally {
283-
reader.releaseLock()
227+
} catch (error) {
228+
if (error instanceof Error) {
229+
throw new Error(`OpenAI Responses API stream error: ${error.message}`)
230+
}
231+
throw error
284232
}
285233
}
286234

@@ -348,10 +296,15 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
348296
const { id, temperature, reasoning } = this.getModel()
349297

350298
if (id === "codex-mini-latest") {
351-
// Make API call using shared helper
352-
const response = await this.makeResponsesApiRequest(id, "Complete the following prompt:", prompt, false)
353-
const data = await response.json()
354-
return data.output_text || ""
299+
// Make API call using OpenAI SDK
300+
const response = await this.createResponsesApiRequest(
301+
id,
302+
"Complete the following prompt:",
303+
prompt,
304+
false,
305+
)
306+
// The SDK response structure may differ from the raw API response
307+
return (response as any).output_text || ""
355308
}
356309

357310
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {

0 commit comments

Comments
 (0)