From 9187168885439c03e8504810e5ea5c7652948231 Mon Sep 17 00:00:00 2001 From: MuriloFP Date: Mon, 28 Jul 2025 20:09:25 -0300 Subject: [PATCH] feat: add native OpenAI provider support for Codex Mini model (#5386) --- packages/types/src/providers/openai.ts | 9 + .../providers/__tests__/openai-native.spec.ts | 121 +++++++++++ src/api/providers/openai-native.ts | 191 ++++++++++++++++++ 3 files changed, 321 insertions(+) diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index 0afdd46feb..977a9ef2cd 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -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 export const openAiModelInfoSaneDefaults: ModelInfo = { diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 64080b4cac..feb161e8e5 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -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) + }) }) }) diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 3f14e65cc6..fb96405beb 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -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 | 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, 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 }],