refactor: migrate OpenAI Codex to AI SDK and use Responses API instructions field (#11352)

This commit is contained in:
Hannes Rudolph 2026-02-09 16:50:40 -07:00 committed by GitHub
parent 34a278e9a2
commit b7d6e4933d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 503 additions and 1062 deletions

View file

@ -1,10 +1,36 @@
// cd src && npx vitest run api/providers/__tests__/openai-codex-native-tool-calls.spec.ts
import { beforeEach, describe, expect, it, vi } from "vitest"
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
mockStreamText: vi.fn(),
mockGenerateText: vi.fn(),
}))
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>()
return {
...actual,
streamText: mockStreamText,
generateText: mockGenerateText,
}
})
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => {
const provider = vi.fn(() => ({
modelId: "gpt-5.2-2025-12-11",
provider: "openai",
}))
;(provider as any).responses = vi.fn(() => ({
modelId: "gpt-5.2-2025-12-11",
provider: "openai.responses",
}))
return provider
}),
}))
import { OpenAiCodexHandler } from "../openai-codex"
import type { ApiHandlerOptions } from "../../../shared/api"
import { NativeToolCallParser } from "../../../core/assistant-message/NativeToolCallParser"
import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth"
describe("OpenAiCodexHandler native tool calls", () => {
@ -13,63 +39,43 @@ describe("OpenAiCodexHandler native tool calls", () => {
beforeEach(() => {
vi.restoreAllMocks()
NativeToolCallParser.clearRawChunkState()
NativeToolCallParser.clearAllStreamingToolCalls()
mockOptions = {
apiModelId: "gpt-5.2-2025-12-11",
// minimal settings; OAuth is mocked below
}
handler = new OpenAiCodexHandler(mockOptions)
})
it("yields tool_call_partial chunks when API returns function_call-only response", async () => {
it("yields tool_call_start, tool_call_delta, and tool_call_end chunks for tool calls via AI SDK", async () => {
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
// Mock OpenAI SDK streaming (preferred path).
;(handler as any).client = {
responses: {
create: vi.fn().mockResolvedValue({
async *[Symbol.asyncIterator]() {
yield {
type: "response.output_item.added",
item: {
type: "function_call",
call_id: "call_1",
name: "attempt_completion",
arguments: "",
},
output_index: 0,
}
yield {
type: "response.function_call_arguments.delta",
delta: '{"result":"hi"}',
// Note: intentionally omit call_id + name to simulate tool-call-only streams.
item_id: "fc_1",
output_index: 0,
}
yield {
type: "response.completed",
response: {
id: "resp_1",
status: "completed",
output: [
{
type: "function_call",
call_id: "call_1",
name: "attempt_completion",
arguments: '{"result":"hi"}',
},
],
usage: { input_tokens: 1, output_tokens: 1 },
},
}
},
}),
},
async function* mockFullStream() {
yield {
type: "tool-input-start",
id: "call_1",
toolName: "attempt_completion",
}
yield {
type: "tool-input-delta",
id: "call_1",
delta: '{"result":"hi"}',
}
yield {
type: "tool-input-end",
id: "call_1",
}
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
providerMetadata: Promise.resolve({
openai: { responseId: "resp_1" },
}),
content: Promise.resolve([]),
})
const stream = handler.createMessage("system", [{ role: "user", content: "hello" } as any], {
taskId: "t",
tools: [],
@ -78,23 +84,112 @@ describe("OpenAiCodexHandler native tool calls", () => {
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
if (chunk.type === "tool_call_partial") {
// Simulate Task.ts behavior so finish_reason handling can emit tool_call_end elsewhere
NativeToolCallParser.processRawChunk({
index: chunk.index,
id: chunk.id,
name: chunk.name,
arguments: chunk.arguments,
})
}
}
const toolChunks = chunks.filter((c) => c.type === "tool_call_partial")
expect(toolChunks.length).toBeGreaterThan(0)
expect(toolChunks[0]).toMatchObject({
type: "tool_call_partial",
const startChunks = chunks.filter((c) => c.type === "tool_call_start")
expect(startChunks.length).toBe(1)
expect(startChunks[0]).toMatchObject({
type: "tool_call_start",
id: "call_1",
name: "attempt_completion",
})
const deltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
expect(deltaChunks.length).toBe(1)
expect(deltaChunks[0]).toMatchObject({
type: "tool_call_delta",
id: "call_1",
delta: '{"result":"hi"}',
})
const endChunks = chunks.filter((c) => c.type === "tool_call_end")
expect(endChunks.length).toBe(1)
expect(endChunks[0]).toMatchObject({
type: "tool_call_end",
id: "call_1",
})
})
it("retries on auth failure and succeeds on second attempt", async () => {
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("expired-token")
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
vi.spyOn(openAiCodexOAuthManager, "forceRefreshAccessToken").mockResolvedValue("fresh-token")
let callCount = 0
mockStreamText.mockImplementation(() => {
callCount++
if (callCount === 1) {
const error = new Error("unauthorized")
;(error as any).status = 401
throw error
}
async function* mockFullStream() {
yield { type: "text-delta", text: "success" }
}
return {
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
providerMetadata: Promise.resolve({
openai: { responseId: "resp_retry" },
}),
content: Promise.resolve([]),
}
})
const stream = handler.createMessage("system", [{ role: "user", content: "hello" } as any], {
taskId: "t",
tools: [],
})
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(callCount).toBe(2)
expect(openAiCodexOAuthManager.forceRefreshAccessToken).toHaveBeenCalledOnce()
const textChunks = chunks.filter((c) => c.type === "text")
expect(textChunks.length).toBe(1)
expect(textChunks[0].text).toBe("success")
})
it("yields usage with totalCost 0 for subscription pricing", async () => {
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
async function* mockFullStream() {
yield { type: "text-delta", text: "response" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }),
providerMetadata: Promise.resolve({
openai: { responseId: "resp_usage" },
}),
content: Promise.resolve([]),
})
const stream = handler.createMessage("system", [{ role: "user", content: "hello" } as any], {
taskId: "t",
tools: [],
})
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((c) => c.type === "usage")
expect(usageChunks.length).toBe(1)
expect(usageChunks[0]).toMatchObject({
type: "usage",
inputTokens: 100,
outputTokens: 50,
totalCost: 0,
})
})
})

View file

@ -1,6 +1,34 @@
// npx vitest run api/providers/__tests__/openai-codex.spec.ts
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
const { mockGenerateText } = vi.hoisted(() => ({
mockGenerateText: vi.fn(),
}))
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>()
return {
...actual,
generateText: mockGenerateText,
}
})
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => {
const provider = vi.fn(() => ({
modelId: "gpt-5.3-codex",
provider: "openai",
}))
;(provider as any).responses = vi.fn(() => ({
modelId: "gpt-5.3-codex",
provider: "openai.responses",
}))
return provider
}),
}))
import { OpenAiCodexHandler } from "../openai-codex"
import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth"
describe("OpenAiCodexHandler.getModel", () => {
it.each(["gpt-5.1", "gpt-5", "gpt-5.1-codex", "gpt-5-codex", "gpt-5-codex-mini"])(
@ -24,3 +52,78 @@ describe("OpenAiCodexHandler.getModel", () => {
expect(model.info).toBeDefined()
})
})
describe("OpenAiCodexHandler constructor", () => {
it("should create an instance", () => {
const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.3-codex" })
expect(handler).toBeInstanceOf(OpenAiCodexHandler)
})
it("should have a sessionId set", () => {
const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.3-codex" })
// sessionId is private, but we can verify via the handler being constructed without error
// and by checking it's a valid instance with internal state
expect(handler).toBeDefined()
// Access sessionId via bracket notation to test the private field
expect((handler as any).sessionId).toBeDefined()
expect(typeof (handler as any).sessionId).toBe("string")
expect((handler as any).sessionId.length).toBeGreaterThan(0)
})
})
describe("OpenAiCodexHandler.isAiSdkProvider", () => {
it("should return true", () => {
const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.3-codex" })
expect(handler.isAiSdkProvider()).toBe(true)
})
})
describe("OpenAiCodexHandler.completePrompt", () => {
let handler: OpenAiCodexHandler
beforeEach(() => {
vi.restoreAllMocks()
handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.3-codex" })
})
it("should return text from generateText", async () => {
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
mockGenerateText.mockResolvedValue({ text: "Hello from Codex!" })
const result = await handler.completePrompt("Say hello")
expect(result).toBe("Hello from Codex!")
expect(mockGenerateText).toHaveBeenCalledOnce()
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Say hello",
}),
)
})
it("should throw transformed error via handleAiSdkError when generateText fails", async () => {
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
mockGenerateText.mockRejectedValue(new Error("API Error"))
await expect(handler.completePrompt("Say hello")).rejects.toThrow("OpenAI Codex")
})
it("should throw when not authenticated", async () => {
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue(null as any)
await expect(handler.completePrompt("Say hello")).rejects.toThrow("Not authenticated with OpenAI Codex")
})
})
describe("OpenAiCodexHandler.getEncryptedContent and getResponseId", () => {
it("should return undefined before any streaming", () => {
const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.3-codex" })
expect(handler.getEncryptedContent()).toBeUndefined()
expect(handler.getResponseId()).toBeUndefined()
})
})

View file

@ -328,7 +328,11 @@ describe("OpenAiNativeHandler", () => {
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
system: systemPrompt,
providerOptions: expect.objectContaining({
openai: expect.objectContaining({
instructions: systemPrompt,
}),
}),
}),
)
})

File diff suppressed because it is too large Load diff

View file

@ -299,6 +299,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
private buildProviderOptions(
model: OpenAiNativeModel,
metadata?: ApiHandlerCreateMessageMetadata,
systemPrompt?: string,
): Record<string, any> {
const reasoningEffort = this.getReasoningEffort(model)
const promptCacheRetention = this.getPromptCacheRetention(model)
@ -309,6 +310,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
const openaiOptions: Record<string, any> = {
store: false,
parallelToolCalls: metadata?.parallelToolCalls ?? true,
...(systemPrompt !== undefined && { instructions: systemPrompt }),
}
if (reasoningEffort) {
@ -444,11 +446,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
"User-Agent": userAgent,
}
const providerOptions = this.buildProviderOptions(model, metadata)
const providerOptions = this.buildProviderOptions(model, metadata, systemPrompt)
const requestOptions: Parameters<typeof streamText>[0] = {
model: languageModel,
system: systemPrompt,
messages: aiSdkMessages,
tools: aiSdkTools,
toolChoice: mapToolChoice(metadata?.tool_choice),