mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: add native OpenAI provider support for Codex Mini model (#5386)
This commit is contained in:
parent
4dd68eab57
commit
9187168885
3 changed files with 321 additions and 0 deletions
|
|
@ -180,6 +180,15 @@ export const openAiNativeModels = {
|
|||
outputPrice: 0.6,
|
||||
cacheReadsPrice: 0.075,
|
||||
},
|
||||
"codex-mini-latest": {
|
||||
maxTokens: 16_384, // Standard max tokens for non-reasoning models
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.5,
|
||||
outputPrice: 6,
|
||||
cacheReadsPrice: 0,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export const openAiModelInfoSaneDefaults: ModelInfo = {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import { ApiHandlerOptions } from "../../../shared/api"
|
|||
|
||||
// Mock OpenAI client
|
||||
const mockCreate = vitest.fn()
|
||||
const mockFetch = vitest.fn()
|
||||
|
||||
// Mock global fetch
|
||||
global.fetch = mockFetch as any
|
||||
|
||||
vitest.mock("openai", () => {
|
||||
return {
|
||||
|
|
@ -84,6 +88,7 @@ describe("OpenAiNativeHandler", () => {
|
|||
}
|
||||
handler = new OpenAiNativeHandler(mockOptions)
|
||||
mockCreate.mockClear()
|
||||
mockFetch.mockClear()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
|
|
@ -441,6 +446,109 @@ describe("OpenAiNativeHandler", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("codex-mini-latest model", () => {
|
||||
beforeEach(() => {
|
||||
handler = new OpenAiNativeHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "codex-mini-latest",
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
}),
|
||||
})
|
||||
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(2)
|
||||
expect(textChunks[0].text).toBe("Hello")
|
||||
expect(textChunks[1].text).toBe(" world")
|
||||
})
|
||||
|
||||
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(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",
|
||||
})
|
||||
|
||||
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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return model info", () => {
|
||||
const modelInfo = handler.getModel()
|
||||
|
|
@ -458,5 +566,18 @@ describe("OpenAiNativeHandler", () => {
|
|||
expect(modelInfo.id).toBe("gpt-4.1") // Default model
|
||||
expect(modelInfo.info).toBeDefined()
|
||||
})
|
||||
|
||||
it("should return correct info for codex-mini-latest", () => {
|
||||
const codexHandler = new OpenAiNativeHandler({
|
||||
apiModelId: "codex-mini-latest",
|
||||
openAiNativeApiKey: "test-api-key",
|
||||
})
|
||||
const modelInfo = codexHandler.getModel()
|
||||
expect(modelInfo.id).toBe("codex-mini-latest")
|
||||
expect(modelInfo.info.maxTokens).toBe(16_384) // Updated to standard max tokens
|
||||
expect(modelInfo.info.contextWindow).toBe(200_000)
|
||||
expect(modelInfo.info.supportsImages).toBe(false)
|
||||
expect(modelInfo.info.supportsPromptCache).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
yield* this.handleReasonerMessage(model, id, systemPrompt, messages)
|
||||
} else if (model.id.startsWith("o1")) {
|
||||
yield* this.handleO1FamilyMessage(model, systemPrompt, messages)
|
||||
} else if (model.id === "codex-mini-latest") {
|
||||
yield* this.handleCodexMiniMessage(model, systemPrompt, messages)
|
||||
} else {
|
||||
yield* this.handleDefaultModelMessage(model, systemPrompt, messages)
|
||||
}
|
||||
|
|
@ -123,6 +125,151 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
yield* this.handleStreamResponse(stream, model)
|
||||
}
|
||||
|
||||
private async *handleCodexMiniMessage(
|
||||
model: OpenAiNativeModel,
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): ApiStream {
|
||||
// Convert messages to a single input string
|
||||
const input = this.convertMessagesToInput(messages)
|
||||
|
||||
// Make direct API call to v1/responses endpoint
|
||||
// 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({
|
||||
model: model.id,
|
||||
instructions: systemPrompt,
|
||||
input: input,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`OpenAI Responses API error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
yield* this.handleResponsesStreamResponse(response.body, model, systemPrompt, input)
|
||||
} 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}`)
|
||||
}
|
||||
throw new Error("Unknown error occurred while calling OpenAI Responses API")
|
||||
}
|
||||
}
|
||||
|
||||
private convertMessagesToInput(messages: Anthropic.Messages.MessageParam[]): string {
|
||||
return messages
|
||||
.map((msg) => {
|
||||
if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
return msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
return msg.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter((content) => content)
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
private async *handleResponsesStreamResponse(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
private async *handleStreamResponse(
|
||||
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
|
||||
model: OpenAiNativeModel,
|
||||
|
|
@ -186,6 +333,50 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
try {
|
||||
const { id, temperature, reasoning } = this.getModel()
|
||||
|
||||
if (id === "codex-mini-latest") {
|
||||
// Make direct API call to v1/responses endpoint
|
||||
// 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({
|
||||
model: id,
|
||||
instructions: "Complete the following prompt:",
|
||||
input: prompt,
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(
|
||||
`OpenAI Responses API error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.output_text || ""
|
||||
} 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}`)
|
||||
}
|
||||
throw new Error("Unknown error occurred while calling OpenAI Responses API")
|
||||
}
|
||||
}
|
||||
|
||||
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
model: id,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue