Skip to content

Commit abfbbc1

Browse files
committed
refactor: use OpenAI SDK for codex-mini-latest responses endpoint
- Replace direct fetch calls with client.responses.create() from OpenAI SDK - Simplify stream handling by leveraging SDK's async iterator - Update tests to mock SDK responses methods instead of global fetch - Add proper error handling with try-catch in handleCodexMiniMessage - Remove ~80 lines of manual SSE parsing logic This change improves maintainability by using the official SDK support for the v1/responses endpoint while maintaining all existing functionality.
1 parent fea2e07 commit abfbbc1

File tree

2 files changed

+82
-177
lines changed

2 files changed

+82
-177
lines changed

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

Lines changed: 30 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ApiHandlerOptions } from "../../../shared/api"
77

88
// Mock OpenAI client
99
const mockCreate = vitest.fn()
10+
const mockResponsesCreate = vitest.fn()
1011
const mockFetch = vitest.fn()
1112

1213
// Mock global fetch
@@ -66,6 +67,9 @@ vitest.mock("openai", () => {
6667
}),
6768
},
6869
},
70+
responses: {
71+
create: mockResponsesCreate,
72+
},
6973
})),
7074
}
7175
})
@@ -88,6 +92,7 @@ describe("OpenAiNativeHandler", () => {
8892
}
8993
handler = new OpenAiNativeHandler(mockOptions)
9094
mockCreate.mockClear()
95+
mockResponsesCreate.mockClear()
9196
mockFetch.mockClear()
9297
})
9398

@@ -455,27 +460,17 @@ describe("OpenAiNativeHandler", () => {
455460
})
456461

457462
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,
463+
// Mock the responses.create method to return an async iterable
464+
mockResponsesCreate.mockImplementation(async (options) => {
465+
expect(options.stream).toBe(true)
466+
467+
return {
468+
[Symbol.asyncIterator]: async function* () {
469+
yield { type: "response.output_text.delta", delta: "Hello" }
470+
yield { type: "response.output_text.delta", delta: " world" }
471+
yield { type: "response.completed" }
472+
},
473+
}
479474
})
480475

481476
const responseStream = handler.createMessage(systemPrompt, messages)
@@ -484,18 +479,11 @@ describe("OpenAiNativeHandler", () => {
484479
chunks.push(chunk)
485480
}
486481

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-
}),
482+
expect(mockResponsesCreate).toHaveBeenCalledWith({
483+
model: "codex-mini-latest",
484+
instructions: systemPrompt,
485+
input: "Hello!",
486+
stream: true,
499487
})
500488

501489
const textChunks = chunks.filter((chunk) => chunk.type === "text")
@@ -505,47 +493,31 @@ 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" }),
496+
mockResponsesCreate.mockResolvedValueOnce({
497+
output_text: "Test response",
512498
})
513499

514500
const result = await handler.completePrompt("Test prompt")
515501

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-
}),
502+
expect(mockResponsesCreate).toHaveBeenCalledWith({
503+
model: "codex-mini-latest",
504+
instructions: "Complete the following prompt:",
505+
input: "Test prompt",
506+
stream: false,
528507
})
529508

530509
expect(result).toBe("Test response")
531510
})
532511

533512
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-
})
513+
mockResponsesCreate.mockRejectedValueOnce(new Error("This model is only supported in v1/responses"))
540514

541515
const stream = handler.createMessage(systemPrompt, messages)
542516
await expect(async () => {
543517
for await (const _chunk of stream) {
544518
// Should not reach here
545519
}
546-
}).rejects.toThrow(
547-
"OpenAI Responses API error: 404 Not Found - This model is only supported in v1/responses",
548-
)
520+
}).rejects.toThrow("OpenAI Responses API error: This model is only supported in v1/responses")
549521
})
550522
})
551523

src/api/providers/openai-native.ts

Lines changed: 52 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -125,68 +125,32 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
125125
yield* this.handleStreamResponse(stream, model)
126126
}
127127

128-
/**
129-
* Makes a request to the OpenAI Responses API endpoint
130-
* Used by codex-mini-latest model which requires the v1/responses endpoint
131-
*/
132-
private async makeResponsesApiRequest(
133-
modelId: string,
134-
instructions: string,
135-
input: string,
136-
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"
128+
private async *handleCodexMiniMessage(
129+
model: OpenAiNativeModel,
130+
systemPrompt: string,
131+
messages: Anthropic.Messages.MessageParam[],
132+
): ApiStream {
133+
// Convert messages to a single input string
134+
const input = this.convertMessagesToInput(messages)
143135

144136
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({
152-
model: modelId,
153-
instructions: instructions,
154-
input: input,
155-
stream: stream,
156-
}),
137+
// Use the OpenAI SDK's responses endpoint
138+
const stream = await this.client.responses.create({
139+
model: model.id,
140+
instructions: systemPrompt,
141+
input: input,
142+
stream: true,
157143
})
158144

159-
if (!response.ok) {
160-
const errorText = await response.text()
161-
throw new Error(`OpenAI Responses API error: ${response.status} ${response.statusText} - ${errorText}`)
162-
}
163-
164-
return response
145+
yield* this.handleResponsesStreamResponse(stream, model, systemPrompt, input)
165146
} 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-
}
170147
if (error instanceof Error) {
171148
throw new Error(`OpenAI Responses API error: ${error.message}`)
172149
}
173-
throw new Error("Unknown error occurred while calling OpenAI Responses API")
150+
throw error
174151
}
175152
}
176153

177-
private async *handleCodexMiniMessage(
178-
model: OpenAiNativeModel,
179-
systemPrompt: string,
180-
messages: Anthropic.Messages.MessageParam[],
181-
): ApiStream {
182-
// Convert messages to a single input string
183-
const input = this.convertMessagesToInput(messages)
184-
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)
188-
}
189-
190154
private convertMessagesToInput(messages: Anthropic.Messages.MessageParam[]): string {
191155
return messages
192156
.map((msg) => {
@@ -207,80 +171,45 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
207171
}
208172

209173
private async *handleResponsesStreamResponse(
210-
stream: ReadableStream<Uint8Array> | null,
174+
stream: AsyncIterable<any>,
211175
model: OpenAiNativeModel,
212176
systemPrompt: string,
213177
userInput: string,
214178
): ApiStream {
215-
if (!stream) {
216-
throw new Error("No response stream available")
217-
}
218-
219179
let totalText = ""
220-
const reader = stream.getReader()
221-
const decoder = new TextDecoder()
222-
let buffer = ""
223180

224181
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-
}
182+
for await (const event of stream) {
183+
// Handle different event types from responses API
184+
if (event.type === "response.output_text.delta") {
185+
yield {
186+
type: "text",
187+
text: event.delta,
279188
}
189+
totalText += event.delta
190+
} else if (event.type === "response.completed") {
191+
// Calculate usage based on text length (approximate)
192+
// Estimate tokens: ~1 token per 4 characters
193+
const promptTokens = Math.ceil((systemPrompt.length + userInput.length) / 4)
194+
const completionTokens = Math.ceil(totalText.length / 4)
195+
yield* this.yieldUsage(model.info, {
196+
prompt_tokens: promptTokens,
197+
completion_tokens: completionTokens,
198+
total_tokens: promptTokens + completionTokens,
199+
})
200+
} else if (event.type === "response.error") {
201+
// Handle error events from the API
202+
throw new Error(`OpenAI Responses API stream error: ${event.error?.message || "Unknown error"}`)
203+
} else {
204+
// Log unknown event types for debugging and future compatibility
205+
console.debug(`OpenAI Responses API: Unknown event type '${event.type}' received`, event)
280206
}
281207
}
282-
} finally {
283-
reader.releaseLock()
208+
} catch (error) {
209+
if (error instanceof Error) {
210+
throw new Error(`OpenAI Responses API error: ${error.message}`)
211+
}
212+
throw error
284213
}
285214
}
286215

@@ -348,10 +277,14 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
348277
const { id, temperature, reasoning } = this.getModel()
349278

350279
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 || ""
280+
// Use the OpenAI SDK's responses endpoint
281+
const response = await this.client.responses.create({
282+
model: id,
283+
instructions: "Complete the following prompt:",
284+
input: prompt,
285+
stream: false,
286+
})
287+
return response.output_text || ""
355288
}
356289

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

0 commit comments

Comments
 (0)