mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
fea2e072af
commit
d0c92ea82f
2 changed files with 88 additions and 168 deletions
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue