mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
fix: route codex models to Responses API in OpenAI Compatible handler
Azure-hosted GPT-5.x codex models (e.g., gpt-5.3-codex) do not support the Chat Completions API and return a 400 error. This change detects codex models by checking if the model ID contains "codex" and routes them through the OpenAI Responses API (responses.create) instead of chat.completions.create. Changes: - Add _isCodexModel() helper to detect codex model IDs - Add handleCodexMessage() that uses the Responses API with full streaming event processing (text, reasoning, tool calls, usage) - Add _formatConversationForResponsesApi() to convert Anthropic message format to Responses API input format - Add _convertToolsForResponsesApi() to convert tools from Chat Completions nested format to Responses API flat format - Update completePrompt() to also use Responses API for codex models - Add comprehensive tests for all new code paths Fixes #11951
This commit is contained in:
parent
44fd975b17
commit
696f7eb2f5
2 changed files with 931 additions and 2 deletions
459
src/api/providers/__tests__/openai-codex-responses.spec.ts
Normal file
459
src/api/providers/__tests__/openai-codex-responses.spec.ts
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
// npx vitest run api/providers/__tests__/openai-codex-responses.spec.ts
|
||||
|
||||
import { OpenAiHandler } from "../openai"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { openAiModelInfoSaneDefaults } from "@roo-code/types"
|
||||
|
||||
const mockChatCreate = vitest.fn()
|
||||
const mockResponsesCreate = vitest.fn()
|
||||
|
||||
vitest.mock("openai", () => {
|
||||
const mockConstructor = vitest.fn()
|
||||
return {
|
||||
__esModule: true,
|
||||
default: mockConstructor.mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockChatCreate,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
create: mockResponsesCreate,
|
||||
},
|
||||
})),
|
||||
AzureOpenAI: mockConstructor.mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockChatCreate,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
create: mockResponsesCreate,
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
describe("OpenAiHandler - Codex model detection", () => {
|
||||
let handler: OpenAiHandler
|
||||
|
||||
beforeEach(() => {
|
||||
mockChatCreate.mockClear()
|
||||
mockResponsesCreate.mockClear()
|
||||
})
|
||||
|
||||
describe("_isCodexModel", () => {
|
||||
it("should detect gpt-5.3-codex as a codex model", () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-5.3-codex",
|
||||
openAiBaseUrl: "https://test.openai.azure.com/openai/deployments/gpt5.3",
|
||||
openAiUseAzure: true,
|
||||
})
|
||||
// Access the protected method via any cast
|
||||
expect((handler as any)._isCodexModel("gpt-5.3-codex")).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect gpt-5.1-codex as a codex model", () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-5.1-codex",
|
||||
})
|
||||
expect((handler as any)._isCodexModel("gpt-5.1-codex")).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect codex in a case-insensitive manner", () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "GPT-5.3-CODEX",
|
||||
})
|
||||
expect((handler as any)._isCodexModel("GPT-5.3-CODEX")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not detect regular models as codex", () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-4",
|
||||
})
|
||||
expect((handler as any)._isCodexModel("gpt-4")).toBe(false)
|
||||
expect((handler as any)._isCodexModel("gpt-4o")).toBe(false)
|
||||
expect((handler as any)._isCodexModel("o3-mini")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage with codex model", () => {
|
||||
it("should use Responses API for codex models instead of Chat Completions", async () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-5.3-codex",
|
||||
openAiBaseUrl: "https://test.openai.azure.com/openai/deployments/gpt5.3",
|
||||
openAiUseAzure: true,
|
||||
})
|
||||
|
||||
// Mock the responses.create to return a streaming async iterable
|
||||
mockResponsesCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: "response.output_text.delta",
|
||||
delta: "Hello from codex!",
|
||||
}
|
||||
yield {
|
||||
type: "response.done",
|
||||
response: {
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("You are a helpful assistant", messages, { taskId: "test" })) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify responses.create was called, NOT chat.completions.create
|
||||
expect(mockResponsesCreate).toHaveBeenCalledTimes(1)
|
||||
expect(mockChatCreate).not.toHaveBeenCalled()
|
||||
|
||||
// Verify the request body structure
|
||||
const requestBody = mockResponsesCreate.mock.calls[0][0]
|
||||
expect(requestBody.model).toBe("gpt-5.3-codex")
|
||||
expect(requestBody.stream).toBe(true)
|
||||
expect(requestBody.instructions).toBe("You are a helpful assistant")
|
||||
expect(requestBody.input).toBeDefined()
|
||||
expect(Array.isArray(requestBody.input)).toBe(true)
|
||||
|
||||
// Verify chunks
|
||||
const textChunks = chunks.filter((c) => c.type === "text")
|
||||
expect(textChunks.length).toBe(1)
|
||||
expect(textChunks[0].text).toBe("Hello from codex!")
|
||||
|
||||
const usageChunks = chunks.filter((c) => c.type === "usage")
|
||||
expect(usageChunks.length).toBe(1)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it("should use Chat Completions for non-codex models", async () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-4",
|
||||
openAiBaseUrl: "https://api.openai.com/v1",
|
||||
})
|
||||
|
||||
mockChatCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [{ delta: { content: "Hello" }, index: 0 }],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [{ delta: {}, index: 0 }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("System", messages, { taskId: "test" })) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify chat.completions.create was called, NOT responses.create
|
||||
expect(mockChatCreate).toHaveBeenCalledTimes(1)
|
||||
expect(mockResponsesCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage codex conversation formatting", () => {
|
||||
it("should format conversation with tool use correctly for Responses API", async () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-5.3-codex",
|
||||
openAiBaseUrl: "https://test.openai.azure.com/openai/deployments/gpt5.3",
|
||||
openAiUseAzure: true,
|
||||
})
|
||||
|
||||
mockResponsesCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: "response.output_text.delta",
|
||||
delta: "Done.",
|
||||
}
|
||||
yield {
|
||||
type: "response.done",
|
||||
response: {
|
||||
usage: { input_tokens: 20, output_tokens: 3 },
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "What is 1+1?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me calculate that." },
|
||||
{ type: "tool_use", id: "call_123", name: "calculator", input: { expression: "1+1" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_123",
|
||||
content: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for await (const _chunk of handler.createMessage("You are helpful", messages, { taskId: "test" })) {
|
||||
// consume
|
||||
}
|
||||
|
||||
const requestBody = mockResponsesCreate.mock.calls[0][0]
|
||||
const input = requestBody.input
|
||||
|
||||
// First item: user message
|
||||
expect(input[0].role).toBe("user")
|
||||
expect(input[0].content[0].type).toBe("input_text")
|
||||
expect(input[0].content[0].text).toBe("What is 1+1?")
|
||||
|
||||
// Second item: assistant text
|
||||
expect(input[1].role).toBe("assistant")
|
||||
expect(input[1].content[0].type).toBe("output_text")
|
||||
|
||||
// Third item: function_call
|
||||
expect(input[2].type).toBe("function_call")
|
||||
expect(input[2].name).toBe("calculator")
|
||||
|
||||
// Fourth item: function_call_output
|
||||
expect(input[3].type).toBe("function_call_output")
|
||||
expect(input[3].output).toBe("2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage codex tool call streaming", () => {
|
||||
it("should handle tool call events from the Responses API", async () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-5.3-codex",
|
||||
openAiUseAzure: true,
|
||||
})
|
||||
|
||||
mockResponsesCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_abc",
|
||||
name: "read_file",
|
||||
},
|
||||
}
|
||||
yield {
|
||||
type: "response.function_call_arguments.delta",
|
||||
call_id: "call_abc",
|
||||
name: "read_file",
|
||||
delta: '{"path":',
|
||||
index: 0,
|
||||
}
|
||||
yield {
|
||||
type: "response.function_call_arguments.delta",
|
||||
call_id: "call_abc",
|
||||
name: "read_file",
|
||||
delta: '"test.ts"}',
|
||||
index: 0,
|
||||
}
|
||||
yield {
|
||||
type: "response.function_call_arguments.done",
|
||||
call_id: "call_abc",
|
||||
}
|
||||
yield {
|
||||
type: "response.done",
|
||||
response: {
|
||||
usage: { input_tokens: 5, output_tokens: 10 },
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage(
|
||||
"System",
|
||||
[{ role: "user", content: "Read test.ts" }],
|
||||
{ taskId: "test" },
|
||||
)) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const partialCalls = chunks.filter((c) => c.type === "tool_call_partial")
|
||||
expect(partialCalls.length).toBe(2)
|
||||
expect(partialCalls[0].id).toBe("call_abc")
|
||||
expect(partialCalls[0].name).toBe("read_file")
|
||||
expect(partialCalls[0].arguments).toBe('{"path":')
|
||||
expect(partialCalls[1].arguments).toBe('"test.ts"}')
|
||||
})
|
||||
|
||||
it("should handle complete tool calls from output_item.done", async () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-5.3-codex",
|
||||
openAiUseAzure: true,
|
||||
})
|
||||
|
||||
mockResponsesCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_xyz",
|
||||
name: "write_file",
|
||||
},
|
||||
}
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_xyz",
|
||||
name: "write_file",
|
||||
arguments: '{"path":"out.txt","content":"hello"}',
|
||||
},
|
||||
}
|
||||
yield {
|
||||
type: "response.done",
|
||||
response: {
|
||||
usage: { input_tokens: 5, output_tokens: 10 },
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("System", [{ role: "user", content: "Write file" }], { taskId: "test" })) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const toolCalls = chunks.filter((c) => c.type === "tool_call")
|
||||
expect(toolCalls.length).toBe(1)
|
||||
expect(toolCalls[0].id).toBe("call_xyz")
|
||||
expect(toolCalls[0].name).toBe("write_file")
|
||||
expect(toolCalls[0].arguments).toBe('{"path":"out.txt","content":"hello"}')
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt with codex model", () => {
|
||||
it("should use Responses API for codex models in completePrompt", async () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-5.3-codex",
|
||||
openAiBaseUrl: "https://test.openai.azure.com/openai/deployments/gpt5.3",
|
||||
openAiUseAzure: true,
|
||||
})
|
||||
|
||||
mockResponsesCreate.mockResolvedValue({
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "Completed prompt response",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Complete this")
|
||||
|
||||
expect(mockResponsesCreate).toHaveBeenCalledTimes(1)
|
||||
expect(mockChatCreate).not.toHaveBeenCalled()
|
||||
|
||||
const requestBody = mockResponsesCreate.mock.calls[0][0]
|
||||
expect(requestBody.model).toBe("gpt-5.3-codex")
|
||||
expect(requestBody.stream).toBe(false)
|
||||
expect(requestBody.input[0].role).toBe("user")
|
||||
|
||||
expect(result).toBe("Completed prompt response")
|
||||
})
|
||||
|
||||
it("should use Chat Completions for non-codex models in completePrompt", async () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-4",
|
||||
openAiBaseUrl: "https://api.openai.com/v1",
|
||||
})
|
||||
|
||||
mockChatCreate.mockResolvedValue({
|
||||
choices: [
|
||||
{
|
||||
message: { role: "assistant", content: "Chat completion response" },
|
||||
finish_reason: "stop",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Complete this")
|
||||
|
||||
expect(mockChatCreate).toHaveBeenCalledTimes(1)
|
||||
expect(mockResponsesCreate).not.toHaveBeenCalled()
|
||||
expect(result).toBe("Chat completion response")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage codex error handling", () => {
|
||||
it("should handle API errors from Responses API", async () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-5.3-codex",
|
||||
openAiUseAzure: true,
|
||||
})
|
||||
|
||||
mockResponsesCreate.mockRejectedValue(new Error("API rate limit exceeded"))
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of handler.createMessage("System", [{ role: "user", content: "Hello" }], { taskId: "test" })) {
|
||||
// consume
|
||||
}
|
||||
}).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("should handle error events in the stream", async () => {
|
||||
handler = new OpenAiHandler({
|
||||
openAiApiKey: "test-key",
|
||||
openAiModelId: "gpt-5.3-codex",
|
||||
openAiUseAzure: true,
|
||||
})
|
||||
|
||||
mockResponsesCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: "response.error",
|
||||
error: { message: "Something went wrong" },
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of handler.createMessage("System", [{ role: "user", content: "Hello" }], { taskId: "test" })) {
|
||||
// consume
|
||||
}
|
||||
}).rejects.toThrow("Responses API error: Something went wrong")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -13,6 +13,8 @@ import {
|
|||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import { TagMatcher } from "../../utils/tag-matcher"
|
||||
import { sanitizeOpenAiCallId } from "../../utils/tool-id"
|
||||
import { isMcpTool } from "../../utils/mcp-name"
|
||||
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
|
|
@ -91,6 +93,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
const isAzureAiInference = this._isAzureAiInference(modelUrl)
|
||||
const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format
|
||||
|
||||
if (this._isCodexModel(modelId)) {
|
||||
yield* this.handleCodexMessage(systemPrompt, messages, metadata)
|
||||
return
|
||||
}
|
||||
|
||||
if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) {
|
||||
yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages, metadata)
|
||||
return
|
||||
|
|
@ -294,12 +301,19 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
try {
|
||||
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
|
||||
const model = this.getModel()
|
||||
const modelId = model.id
|
||||
const modelInfo = model.info
|
||||
|
||||
// Codex models must use the Responses API
|
||||
if (this._isCodexModel(modelId)) {
|
||||
return this._completePromptWithResponsesApi(prompt, model)
|
||||
}
|
||||
|
||||
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
model: model.id,
|
||||
model: modelId,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}
|
||||
|
||||
|
|
@ -326,6 +340,58 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a prompt using the Responses API (for codex models).
|
||||
*/
|
||||
private async _completePromptWithResponsesApi(
|
||||
prompt: string,
|
||||
model: ReturnType<OpenAiHandler["getModel"]>,
|
||||
): Promise<string> {
|
||||
const requestBody: any = {
|
||||
model: model.id,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: prompt }],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
store: false,
|
||||
}
|
||||
|
||||
// Add max_output_tokens if needed
|
||||
if (this.options.includeMaxTokens === true) {
|
||||
requestBody.max_output_tokens = this.options.modelMaxTokens || model.info.maxTokens
|
||||
}
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await (this.client as any).responses.create(requestBody)
|
||||
} catch (error) {
|
||||
throw handleOpenAIError(error, this.providerName)
|
||||
}
|
||||
|
||||
// Extract text from the Responses API response
|
||||
if (response?.output && Array.isArray(response.output)) {
|
||||
for (const outputItem of response.output) {
|
||||
if (outputItem.type === "message" && outputItem.content) {
|
||||
for (const content of outputItem.content) {
|
||||
if (content.type === "output_text" && content.text) {
|
||||
return content.text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check for direct text in response
|
||||
if (response?.text) {
|
||||
return response.text
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
private async *handleO3FamilyMessage(
|
||||
modelId: string,
|
||||
systemPrompt: string,
|
||||
|
|
@ -496,6 +562,410 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model is a codex model that requires the Responses API.
|
||||
* Azure-hosted GPT-5.x codex models (e.g., gpt-5.3-codex) do not support
|
||||
* the Chat Completions API and must use the Responses API instead.
|
||||
*/
|
||||
protected _isCodexModel(modelId: string): boolean {
|
||||
return modelId.toLowerCase().includes("codex")
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles message creation for codex models using the OpenAI Responses API.
|
||||
* Codex models (e.g., gpt-5.3-codex on Azure) only support the Responses API,
|
||||
* not the Chat Completions API.
|
||||
*/
|
||||
private async *handleCodexMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const model = this.getModel()
|
||||
|
||||
// Format conversation for the Responses API
|
||||
const formattedInput = this._formatConversationForResponsesApi(messages)
|
||||
|
||||
// Build tools in Responses API format (flat structure, not nested under function)
|
||||
const tools = this._convertToolsForResponsesApi(metadata?.tools)
|
||||
|
||||
// Build the request body
|
||||
const requestBody: any = {
|
||||
model: model.id,
|
||||
input: formattedInput,
|
||||
stream: true,
|
||||
store: false,
|
||||
instructions: systemPrompt,
|
||||
...(tools && tools.length > 0 ? { tools } : {}),
|
||||
...(metadata?.tool_choice ? { tool_choice: metadata.tool_choice } : {}),
|
||||
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
|
||||
}
|
||||
|
||||
// Add temperature
|
||||
if (model.info.supportsTemperature !== false) {
|
||||
requestBody.temperature = this.options.modelTemperature ?? 0
|
||||
}
|
||||
|
||||
// Add max_output_tokens if needed
|
||||
if (this.options.includeMaxTokens === true) {
|
||||
requestBody.max_output_tokens = this.options.modelMaxTokens || model.info.maxTokens
|
||||
}
|
||||
|
||||
// State tracking for streaming
|
||||
let pendingToolCallId: string | undefined
|
||||
let pendingToolCallName: string | undefined
|
||||
let sawTextOutput = false
|
||||
const streamedToolCallIds = new Set<string>()
|
||||
|
||||
try {
|
||||
const stream = (await (this.client as any).responses.create(requestBody)) as AsyncIterable<any>
|
||||
|
||||
for await (const event of stream) {
|
||||
// Handle text deltas
|
||||
if (event?.type === "response.text.delta" || event?.type === "response.output_text.delta") {
|
||||
if (event?.delta) {
|
||||
sawTextOutput = true
|
||||
yield { type: "text", text: event.delta }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle done-only text for variants that skip delta events
|
||||
if (event?.type === "response.text.done" || event?.type === "response.output_text.done") {
|
||||
const doneText =
|
||||
typeof event?.text === "string"
|
||||
? event.text
|
||||
: typeof event?.output_text === "string"
|
||||
? event.output_text
|
||||
: undefined
|
||||
if (!sawTextOutput && doneText) {
|
||||
sawTextOutput = true
|
||||
yield { type: "text", text: doneText }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle content part events
|
||||
if (event?.type === "response.content_part.added" || event?.type === "response.content_part.done") {
|
||||
const part = event?.part
|
||||
if (
|
||||
!sawTextOutput &&
|
||||
(part?.type === "text" || part?.type === "output_text") &&
|
||||
typeof part?.text === "string" &&
|
||||
part.text
|
||||
) {
|
||||
sawTextOutput = true
|
||||
yield { type: "text", text: part.text }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle reasoning deltas
|
||||
if (
|
||||
event?.type === "response.reasoning.delta" ||
|
||||
event?.type === "response.reasoning_text.delta" ||
|
||||
event?.type === "response.reasoning_summary.delta" ||
|
||||
event?.type === "response.reasoning_summary_text.delta"
|
||||
) {
|
||||
if (event?.delta) {
|
||||
yield { type: "reasoning", text: event.delta }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle refusal deltas
|
||||
if (event?.type === "response.refusal.delta") {
|
||||
if (event?.delta) {
|
||||
sawTextOutput = true
|
||||
yield { type: "text", text: `[Refusal] ${event.delta}` }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle output item events (track tool identity)
|
||||
if (event?.type === "response.output_item.added" || event?.type === "response.output_item.done") {
|
||||
const item = event?.item
|
||||
if (item) {
|
||||
// Capture tool identity for subsequent argument deltas
|
||||
if (item.type === "function_call" || item.type === "tool_call") {
|
||||
const callId = item.call_id || item.tool_call_id || item.id
|
||||
const name = item.name || item.function?.name
|
||||
if (typeof callId === "string" && callId.length > 0) {
|
||||
pendingToolCallId = callId
|
||||
pendingToolCallName = typeof name === "string" ? name : undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "response.output_item.added") {
|
||||
if ((item.type === "text" || item.type === "output_text") && item.text) {
|
||||
sawTextOutput = true
|
||||
yield { type: "text", text: item.text }
|
||||
} else if (item.type === "message" && Array.isArray(item.content)) {
|
||||
for (const content of item.content) {
|
||||
if (
|
||||
(content?.type === "text" || content?.type === "output_text") &&
|
||||
content?.text
|
||||
) {
|
||||
sawTextOutput = true
|
||||
yield { type: "text", text: content.text }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
event.type === "response.output_item.done" &&
|
||||
(item.type === "function_call" || item.type === "tool_call")
|
||||
) {
|
||||
const callId = item.call_id || item.tool_call_id || item.id
|
||||
const name = item.name || item.function?.name
|
||||
const argsRaw = item.arguments || item.function?.arguments || item.input
|
||||
const args =
|
||||
typeof argsRaw === "string"
|
||||
? argsRaw
|
||||
: argsRaw && typeof argsRaw === "object"
|
||||
? JSON.stringify(argsRaw)
|
||||
: ""
|
||||
|
||||
if (
|
||||
typeof callId === "string" &&
|
||||
callId.length > 0 &&
|
||||
typeof name === "string" &&
|
||||
name.length > 0 &&
|
||||
!streamedToolCallIds.has(callId)
|
||||
) {
|
||||
yield { type: "tool_call", id: callId, name, arguments: args }
|
||||
}
|
||||
} else if (!sawTextOutput) {
|
||||
if ((item.type === "text" || item.type === "output_text") && item.text) {
|
||||
sawTextOutput = true
|
||||
yield { type: "text", text: item.text }
|
||||
} else if (item.type === "message" && Array.isArray(item.content)) {
|
||||
for (const content of item.content) {
|
||||
if (
|
||||
(content?.type === "text" || content?.type === "output_text") &&
|
||||
content?.text
|
||||
) {
|
||||
sawTextOutput = true
|
||||
yield { type: "text", text: content.text }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle tool/function call argument deltas
|
||||
if (
|
||||
event?.type === "response.tool_call_arguments.delta" ||
|
||||
event?.type === "response.function_call_arguments.delta"
|
||||
) {
|
||||
const callId = event.call_id || event.tool_call_id || event.id || pendingToolCallId || undefined
|
||||
const name = event.name || event.function_name || pendingToolCallName || undefined
|
||||
const args = event.delta || event.arguments
|
||||
|
||||
if (
|
||||
typeof name === "string" &&
|
||||
name.length > 0 &&
|
||||
typeof callId === "string" &&
|
||||
callId.length > 0
|
||||
) {
|
||||
streamedToolCallIds.add(callId)
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: event.index ?? 0,
|
||||
id: callId,
|
||||
name,
|
||||
arguments: args,
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle tool/function call completion
|
||||
if (
|
||||
event?.type === "response.tool_call_arguments.done" ||
|
||||
event?.type === "response.function_call_arguments.done"
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle completion events with usage
|
||||
if (event?.type === "response.done" || event?.type === "response.completed") {
|
||||
// Fallback text extraction from final payload
|
||||
if (!sawTextOutput && Array.isArray(event?.response?.output)) {
|
||||
for (const outputItem of event.response.output) {
|
||||
if (
|
||||
(outputItem?.type === "text" || outputItem?.type === "output_text") &&
|
||||
outputItem?.text
|
||||
) {
|
||||
sawTextOutput = true
|
||||
yield { type: "text", text: outputItem.text }
|
||||
continue
|
||||
}
|
||||
if (outputItem?.type === "message" && Array.isArray(outputItem.content)) {
|
||||
for (const content of outputItem.content) {
|
||||
if (
|
||||
(content?.type === "text" || content?.type === "output_text") &&
|
||||
content?.text
|
||||
) {
|
||||
sawTextOutput = true
|
||||
yield { type: "text", text: content.text }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract usage
|
||||
const usage = event?.response?.usage || event?.usage
|
||||
if (usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens ?? usage.prompt_tokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? usage.completion_tokens ?? 0,
|
||||
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens || undefined,
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle error events
|
||||
if (event?.type === "response.error" || event?.type === "error") {
|
||||
if (event.error || event.message) {
|
||||
throw new Error(
|
||||
`Responses API error: ${event.error?.message || event.message || "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle failed event
|
||||
if (event?.type === "response.failed") {
|
||||
if (event.error || event.message) {
|
||||
throw new Error(
|
||||
`Response failed: ${event.error?.message || event.message || "Unknown failure"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for older formats
|
||||
if (event?.choices?.[0]?.delta?.content) {
|
||||
yield { type: "text", text: event.choices[0].delta.content }
|
||||
}
|
||||
|
||||
if (event?.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: event.usage.input_tokens ?? event.usage.prompt_tokens ?? 0,
|
||||
outputTokens: event.usage.output_tokens ?? event.usage.completion_tokens ?? 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw handleOpenAIError(error, this.providerName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an Anthropic message array into the Responses API input format.
|
||||
*/
|
||||
private _formatConversationForResponsesApi(messages: Anthropic.Messages.MessageParam[]): any[] {
|
||||
const formattedInput: any[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
const content: any[] = []
|
||||
const toolResults: any[] = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
content.push({ type: "input_text", text: message.content })
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
content.push({ type: "input_text", text: block.text })
|
||||
} else if (block.type === "image") {
|
||||
const image = block as Anthropic.Messages.ImageBlockParam
|
||||
const imageUrl = `data:${image.source.media_type};base64,${image.source.data}`
|
||||
content.push({ type: "input_image", image_url: imageUrl })
|
||||
} else if (block.type === "tool_result") {
|
||||
const result =
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: block.content?.map((c: any) => (c.type === "text" ? c.text : "")).join("") || ""
|
||||
toolResults.push({
|
||||
type: "function_call_output",
|
||||
call_id: sanitizeOpenAiCallId(block.tool_use_id),
|
||||
output: result,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length > 0) {
|
||||
formattedInput.push({ role: "user", content })
|
||||
}
|
||||
if (toolResults.length > 0) {
|
||||
formattedInput.push(...toolResults)
|
||||
}
|
||||
} else if (message.role === "assistant") {
|
||||
const content: any[] = []
|
||||
const toolCalls: any[] = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
content.push({ type: "output_text", text: message.content })
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
content.push({ type: "output_text", text: block.text })
|
||||
} else if (block.type === "tool_use") {
|
||||
toolCalls.push({
|
||||
type: "function_call",
|
||||
call_id: sanitizeOpenAiCallId(block.id),
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length > 0) {
|
||||
formattedInput.push({ role: "assistant", content })
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
formattedInput.push(...toolCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formattedInput
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts tools from the Chat Completions format to the Responses API format.
|
||||
* The Responses API uses a flat structure: {type, name, description, parameters, strict}
|
||||
* instead of the nested {type, function: {name, description, parameters}} format.
|
||||
*/
|
||||
private _convertToolsForResponsesApi(tools: any[] | undefined): any[] | undefined {
|
||||
if (!tools || tools.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return tools
|
||||
.filter((tool: any) => tool.type === "function")
|
||||
.map((tool: any) => {
|
||||
const isMcp = isMcpTool(tool.function.name)
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: isMcp
|
||||
? tool.function.parameters
|
||||
: this.convertToolSchemaForOpenAI(tool.function.parameters),
|
||||
strict: !isMcp,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected _getUrlHost(baseUrl?: string): string {
|
||||
try {
|
||||
return new URL(baseUrl ?? "").host
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue