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
This commit is contained in:
Roo Code 2025-07-29 17:55:57 +00:00
parent fea2e072af
commit d0c92ea82f
2 changed files with 88 additions and 168 deletions

View file

@ -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")
})
})

View file

@ -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<Response> {
// 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<Uint8Array> | 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 = {