From d0c92ea82ffeed8b59715d09f4f6e5b1aff90b37 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 29 Jul 2025 17:55:57 +0000 Subject: [PATCH] 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 --- .../providers/__tests__/openai-native.spec.ts | 101 ++++-------- src/api/providers/openai-native.ts | 155 ++++++------------ 2 files changed, 88 insertions(+), 168 deletions(-) diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index feb161e8e5..02b749023d 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -7,10 +7,8 @@ import { ApiHandlerOptions } from "../../../shared/api" // Mock OpenAI client const mockCreate = vitest.fn() -const mockFetch = vitest.fn() - -// Mock global fetch -global.fetch = mockFetch as any +const mockResponsesCreate = vitest.fn() +const mockResponsesStream = vitest.fn() vitest.mock("openai", () => { return { @@ -66,6 +64,26 @@ vitest.mock("openai", () => { }), }, }, + responses: { + create: mockResponsesCreate.mockImplementation(async () => ({ + output_text: "Test response", + })), + stream: mockResponsesStream.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + type: "response.output_text.delta", + delta: "Hello", + } + yield { + type: "response.output_text.delta", + delta: " world", + } + yield { + type: "response.completed", + } + }, + })), + }, })), } }) @@ -88,7 +106,8 @@ describe("OpenAiNativeHandler", () => { } handler = new OpenAiNativeHandler(mockOptions) mockCreate.mockClear() - mockFetch.mockClear() + mockResponsesCreate.mockClear() + mockResponsesStream.mockClear() }) describe("constructor", () => { @@ -455,47 +474,16 @@ describe("OpenAiNativeHandler", () => { }) it("should handle streaming responses via v1/responses", async () => { - const mockStreamData = [ - 'data: {"type": "response.output_text.delta", "delta": "Hello"}\n', - 'data: {"type": "response.output_text.delta", "delta": " world"}\n', - 'data: {"type": "response.completed"}\n', - "data: [DONE]\n", - ] - - const encoder = new TextEncoder() - const stream = new ReadableStream({ - start(controller) { - for (const data of mockStreamData) { - controller.enqueue(encoder.encode(data)) - } - controller.close() - }, - }) - - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - body: stream, - }) - const responseStream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of responseStream) { chunks.push(chunk) } - expect(mockFetch).toHaveBeenCalledWith("https://api.openai.com/v1/responses", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer test-api-key", - }, - body: JSON.stringify({ - model: "codex-mini-latest", - instructions: systemPrompt, - input: "Hello!", - stream: true, - }), + expect(mockResponsesStream).toHaveBeenCalledWith({ + model: "codex-mini-latest", + instructions: systemPrompt, + input: "Hello!", }) const textChunks = chunks.filter((chunk) => chunk.type === "text") @@ -505,47 +493,26 @@ describe("OpenAiNativeHandler", () => { }) it("should handle non-streaming completion via v1/responses", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ output_text: "Test response" }), - }) - const result = await handler.completePrompt("Test prompt") - expect(mockFetch).toHaveBeenCalledWith("https://api.openai.com/v1/responses", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer test-api-key", - }, - body: JSON.stringify({ - model: "codex-mini-latest", - instructions: "Complete the following prompt:", - input: "Test prompt", - stream: false, - }), + expect(mockResponsesCreate).toHaveBeenCalledWith({ + model: "codex-mini-latest", + instructions: "Complete the following prompt:", + input: "Test prompt", }) expect(result).toBe("Test response") }) it("should handle API errors", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - statusText: "Not Found", - text: async () => "This model is only supported in v1/responses", - }) + mockResponsesStream.mockRejectedValueOnce(new Error("API Error")) const stream = handler.createMessage(systemPrompt, messages) await expect(async () => { for await (const _chunk of stream) { // Should not reach here } - }).rejects.toThrow( - "OpenAI Responses API error: 404 Not Found - This model is only supported in v1/responses", - ) + }).rejects.toThrow("OpenAI Responses API error: API Error") }) }) diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index b4b6024b38..45470f1407 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -126,47 +126,30 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } /** - * Makes a request to the OpenAI Responses API endpoint + * Makes a request to the OpenAI Responses API endpoint using the OpenAI SDK * Used by codex-mini-latest model which requires the v1/responses endpoint */ - private async makeResponsesApiRequest( + private async createResponsesApiRequest( modelId: string, instructions: string, input: string, stream: boolean = true, - ): Promise { - // Note: Using fetch() instead of OpenAI client because the OpenAI SDK v5.0.0 - // does not support the v1/responses endpoint used by codex-mini-latest model. - // This is a special endpoint that requires a different request/response format. - const apiKey = this.options.openAiNativeApiKey ?? "not-provided" - const baseURL = this.options.openAiNativeBaseUrl ?? "https://api.openai.com/v1" - + ) { try { - const response = await fetch(`${baseURL}/responses`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ + if (stream) { + return await this.client.responses.stream({ model: modelId, instructions: instructions, input: input, - stream: stream, - }), - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`OpenAI Responses API error: ${response.status} ${response.statusText} - ${errorText}`) + }) + } else { + return await this.client.responses.create({ + model: modelId, + instructions: instructions, + input: input, + }) } - - return response } catch (error) { - // Handle network failures and other errors - if (error instanceof TypeError && error.message.includes("fetch")) { - throw new Error(`Network error while calling OpenAI Responses API: ${error.message}`) - } if (error instanceof Error) { throw new Error(`OpenAI Responses API error: ${error.message}`) } @@ -182,9 +165,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Convert messages to a single input string const input = this.convertMessagesToInput(messages) - // Make API call using shared helper - const response = await this.makeResponsesApiRequest(model.id, systemPrompt, input, true) - yield* this.handleResponsesStreamResponse(response.body, model, systemPrompt, input) + // Make API call using OpenAI SDK + const stream = await this.createResponsesApiRequest(model.id, systemPrompt, input, true) + yield* this.handleResponsesSDKStreamResponse(stream, model, systemPrompt, input) } private convertMessagesToInput(messages: Anthropic.Messages.MessageParam[]): string { @@ -206,81 +189,46 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio .join("\n\n") } - private async *handleResponsesStreamResponse( - stream: ReadableStream | null, + private async *handleResponsesSDKStreamResponse( + stream: any, // OpenAI SDK stream type model: OpenAiNativeModel, systemPrompt: string, userInput: string, ): ApiStream { - if (!stream) { - throw new Error("No response stream available") - } - let totalText = "" - const reader = stream.getReader() - const decoder = new TextDecoder() - let buffer = "" try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() || "" - - for (const line of lines) { - if (line.trim() === "") continue - if (line.startsWith("data: ")) { - const data = line.slice(6) - if (data === "[DONE]") continue - - try { - const event = JSON.parse(data) - // Handle different event types from responses API - if (event.type === "response.output_text.delta") { - yield { - type: "text", - text: event.delta, - } - totalText += event.delta - } else if (event.type === "response.completed") { - // Calculate usage based on text length (approximate) - // Estimate tokens: ~1 token per 4 characters - const promptTokens = Math.ceil((systemPrompt.length + userInput.length) / 4) - const completionTokens = Math.ceil(totalText.length / 4) - yield* this.yieldUsage(model.info, { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: promptTokens + completionTokens, - }) - } else if (event.type === "response.error") { - // Handle error events from the API - throw new Error( - `OpenAI Responses API stream error: ${event.error?.message || "Unknown error"}`, - ) - } else { - // Log unknown event types for debugging and future compatibility - console.debug( - `OpenAI Responses API: Unknown event type '${event.type}' received`, - event, - ) - } - } catch (e) { - // Only skip if it's a JSON parsing error - if (e instanceof SyntaxError) { - console.debug("OpenAI Responses API: Failed to parse SSE data", data) - } else { - // Re-throw other errors (like API errors) - throw e - } - } + for await (const chunk of stream) { + // Handle different event types from responses API + if (chunk.type === "response.output_text.delta") { + yield { + type: "text", + text: chunk.delta, } + totalText += chunk.delta + } else if (chunk.type === "response.completed") { + // Calculate usage based on text length (approximate) + // Estimate tokens: ~1 token per 4 characters + const promptTokens = Math.ceil((systemPrompt.length + userInput.length) / 4) + const completionTokens = Math.ceil(totalText.length / 4) + yield* this.yieldUsage(model.info, { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }) + } else if (chunk.type === "response.error") { + // Handle error events from the API + throw new Error(`OpenAI Responses API stream error: ${chunk.error?.message || "Unknown error"}`) + } else { + // Log unknown event types for debugging and future compatibility + console.debug(`OpenAI Responses API: Unknown event type '${chunk.type}' received`, chunk) } } - } finally { - reader.releaseLock() + } catch (error) { + if (error instanceof Error) { + throw new Error(`OpenAI Responses API stream error: ${error.message}`) + } + throw error } } @@ -348,10 +296,15 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const { id, temperature, reasoning } = this.getModel() if (id === "codex-mini-latest") { - // Make API call using shared helper - const response = await this.makeResponsesApiRequest(id, "Complete the following prompt:", prompt, false) - const data = await response.json() - return data.output_text || "" + // Make API call using OpenAI SDK + const response = await this.createResponsesApiRequest( + id, + "Complete the following prompt:", + prompt, + false, + ) + // The SDK response structure may differ from the raw API response + return (response as any).output_text || "" } const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {