feat: add native OpenAI provider support for Codex Mini model (#5386)

This commit is contained in:
MuriloFP 2025-07-28 20:09:25 -03:00
parent 4dd68eab57
commit 9187168885
3 changed files with 321 additions and 0 deletions

View file

@ -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 = {

View file

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

View file

@ -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 }],