mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
refactor: migrate OpenAI Codex to AI SDK and use Responses API instructions field (#11352)
This commit is contained in:
parent
34a278e9a2
commit
b7d6e4933d
5 changed files with 503 additions and 1062 deletions
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue