Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/api/providers/__tests__/openai-native-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,4 +296,83 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => {
expect(tool.parameters.properties.metadata.additionalProperties).toBe(false) // Nested object
expect(tool.parameters.properties.metadata.properties.labels.items.additionalProperties).toBe(false) // Array items
})

it("should handle missing call_id and name in tool_call_arguments.delta by using pending tool identity", async () => {
const handler = new OpenAiNativeHandler({
openAiNativeApiKey: "test-key",
apiModelId: "gpt-4o",
} as ApiHandlerOptions)

const mockClient = {
responses: {
create: vi.fn().mockImplementation(() => {
return {
[Symbol.asyncIterator]: async function* () {
// 1. Emit output_item.added with tool identity
yield {
type: "response.output_item.added",
item: {
type: "function_call",
call_id: "call_123",
name: "read_file",
arguments: "",
},
}

// 2. Emit tool_call_arguments.delta WITHOUT identity (just args)
yield {
type: "response.function_call_arguments.delta",
delta: '{"path":',
}

// 3. Emit another delta
yield {
type: "response.function_call_arguments.delta",
delta: '"/tmp/test.txt"}',
}

// 4. Emit output_item.done
yield {
type: "response.output_item.done",
item: {
type: "function_call",
call_id: "call_123",
name: "read_file",
arguments: '{"path":"/tmp/test.txt"}',
},
}
},
}
}),
},
}
;(handler as any).client = mockClient

const stream = handler.createMessage("system prompt", [], {
taskId: "test-task-id",
})

const chunks: any[] = []
for await (const chunk of stream) {
if (chunk.type === "tool_call_partial") {
chunks.push(chunk)
}
}

expect(chunks.length).toBe(2)
expect(chunks[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_123", // Should be filled from pendingToolCallId
name: "read_file", // Should be filled from pendingToolCallName
arguments: '{"path":',
})
expect(chunks[1]).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_123",
name: "read_file",
arguments: '"/tmp/test.txt"}',
})
})
})
2 changes: 0 additions & 2 deletions src/api/providers/__tests__/openai-native.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,6 @@ describe("OpenAiNativeHandler", () => {
headers: expect.objectContaining({
"Content-Type": "application/json",
Authorization: "Bearer test-api-key",
Accept: "text/event-stream",
}),
body: expect.any(String),
}),
Expand Down Expand Up @@ -1325,7 +1324,6 @@ describe("GPT-5 streaming event coverage (additional)", () => {
headers: expect.objectContaining({
"Content-Type": "application/json",
Authorization: "Bearer test-api-key",
Accept: "text/event-stream",
}),
body: expect.any(String),
}),
Expand Down
10 changes: 4 additions & 6 deletions src/api/providers/openai-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"

import { Package } from "../../shared/package"
import type { ApiHandlerOptions } from "../../shared/api"

import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
Expand All @@ -25,9 +26,6 @@ import { isMcpTool } from "../../utils/mcp-name"
import { openAiCodexOAuthManager } from "../../integrations/openai-codex/oauth"
import { t } from "../../i18n"

// Get extension version for User-Agent header
const extensionVersion: string = require("../../package.json").version ?? "unknown"

export type OpenAiCodexModel = ReturnType<OpenAiCodexHandler["getModel"]>

/**
Expand Down Expand Up @@ -354,7 +352,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
const codexHeaders: Record<string, string> = {
originator: "roo-code",
session_id: taskId || this.sessionId,
"User-Agent": `roo-code/${extensionVersion} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`,
"User-Agent": `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`,
...(accountId ? { "ChatGPT-Account-Id": accountId } : {}),
}

Expand Down Expand Up @@ -494,7 +492,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
Authorization: `Bearer ${accessToken}`,
originator: "roo-code",
session_id: taskId || this.sessionId,
"User-Agent": `roo-code/${extensionVersion} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`,
"User-Agent": `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`,
}

// Add ChatGPT-Account-Id if available (required for organization subscriptions)
Expand Down Expand Up @@ -1058,7 +1056,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
Authorization: `Bearer ${accessToken}`,
originator: "roo-code",
session_id: this.sessionId,
"User-Agent": `roo-code/${extensionVersion} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`,
"User-Agent": `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`,
}

// Add ChatGPT-Account-Id if available
Expand Down
91 changes: 78 additions & 13 deletions src/api/providers/openai-native.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as os from "os"
import { v7 as uuidv7 } from "uuid"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"

import { Package } from "../../shared/package"
import {
type ModelInfo,
openAiNativeDefaultModelId,
Expand Down Expand Up @@ -32,6 +35,15 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
protected options: ApiHandlerOptions
private client: OpenAI
private readonly providerName = "OpenAI Native"
// Session ID for request tracking (persists for the lifetime of the handler)
private readonly sessionId: string
/**
* Some Responses streams emit tool-call argument deltas without stable call id/name.
* Track the last observed tool identity from output_item events so we can still
* emit `tool_call_partial` chunks (tool-call-only streams).
*/
private pendingToolCallId: string | undefined
private pendingToolCallName: string | undefined
// Resolved service tier from Responses API (actual tier used by OpenAI)
private lastServiceTier: ServiceTier | undefined
// Complete response output array (includes reasoning items with encrypted_content)
Expand All @@ -51,6 +63,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
"response.reasoning_summary_text.delta",
"response.refusal.delta",
"response.output_item.added",
"response.output_item.done",
"response.done",
"response.completed",
"response.tool_call_arguments.delta",
Expand All @@ -62,13 +75,25 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
constructor(options: ApiHandlerOptions) {
super()
this.options = options
// Generate a session ID for request tracking
this.sessionId = uuidv7()
// Default to including reasoning.summary: "auto" for models that support Responses API
// reasoning summaries unless explicitly disabled.
if (this.options.enableResponsesReasoningSummary === undefined) {
this.options.enableResponsesReasoningSummary = true
}
const apiKey = this.options.openAiNativeApiKey ?? "not-provided"
this.client = new OpenAI({ baseURL: this.options.openAiNativeBaseUrl, apiKey })
// Include originator, session_id, and User-Agent headers for API tracking and debugging
const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`
this.client = new OpenAI({
baseURL: this.options.openAiNativeBaseUrl,
apiKey,
defaultHeaders: {
originator: "roo-code",
session_id: this.sessionId,
"User-Agent": userAgent,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small concerns with the new request-tracking headers: (1) "session_id" is set to taskId when present, so it is no longer a stable session identifier; consider using a separate "task_id" header to avoid confusing downstream logs. (2) Some clients/proxies treat "User-Agent" as restricted; if this causes failures, using the existing DEFAULT_HEADERS user-agent shape might be safer.

Fix it with Roo Code or mention @roomote and request a fix.

})
}

private normalizeUsage(usage: any, model: OpenAiNativeModel): ApiStreamUsageChunk | undefined {
Expand Down Expand Up @@ -155,6 +180,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
this.lastResponseOutput = undefined
// Reset last response id for this request
this.lastResponseId = undefined
// Reset pending tool identity for this request
this.pendingToolCallId = undefined
this.pendingToolCallName = undefined

// Use Responses API for ALL models
const { verbosity, reasoning } = this.getModel()
Expand Down Expand Up @@ -379,10 +407,20 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
// Create AbortController for cancellation
this.abortController = new AbortController()

// Build per-request headers using taskId when available, falling back to sessionId
const taskId = metadata?.taskId
const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`
const requestHeaders: Record<string, string> = {
originator: "roo-code",
session_id: taskId || this.sessionId,
"User-Agent": userAgent,
}

try {
// Use the official SDK
// Use the official SDK with per-request headers
const stream = (await (this.client as any).responses.create(requestBody, {
signal: this.abortController.signal,
headers: requestHeaders,
})) as AsyncIterable<any>

if (typeof (stream as any)[Symbol.asyncIterator] !== "function") {
Expand Down Expand Up @@ -515,13 +553,19 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
// Create AbortController for cancellation
this.abortController = new AbortController()

// Build per-request headers using taskId when available, falling back to sessionId
const taskId = metadata?.taskId
const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`

try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
Accept: "text/event-stream",
originator: "roo-code",
session_id: taskId || this.sessionId,
"User-Agent": userAgent,
},
body: JSON.stringify(requestBody),
signal: this.abortController.signal,
Expand Down Expand Up @@ -666,7 +710,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
if (parsed?.type && this.coreHandledEventTypes.has(parsed.type)) {
for await (const outChunk of this.processEvent(parsed, model)) {
// Track whether we've emitted any content so fallback handling can decide appropriately
if (outChunk.type === "text" || outChunk.type === "reasoning") {
// Include tool calls so tool-call-only responses aren't treated as empty
if (
outChunk.type === "text" ||
outChunk.type === "reasoning" ||
outChunk.type === "tool_call" ||
outChunk.type === "tool_call_partial"
) {
hasContent = true
}
yield outChunk
Expand Down Expand Up @@ -1136,17 +1186,22 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
event?.type === "response.tool_call_arguments.delta" ||
event?.type === "response.function_call_arguments.delta"
) {
// Emit partial chunks directly - NativeToolCallParser handles state management
const callId = event.call_id || event.tool_call_id || event.id
const name = event.name || event.function_name
// Some streams omit stable identity on delta events; fall back to the
// most recently observed tool identity from output_item events.
const callId = event.call_id || event.tool_call_id || event.id || this.pendingToolCallId || undefined
const name = event.name || event.function_name || this.pendingToolCallName || undefined
const args = event.delta || event.arguments

yield {
type: "tool_call_partial",
index: event.index ?? 0,
id: callId,
name,
arguments: args,
// Avoid emitting incomplete tool_call_partial chunks; the downstream
// NativeToolCallParser needs a name to start a call.
if (typeof name === "string" && name.length > 0 && typeof callId === "string" && callId.length > 0) {
yield {
type: "tool_call_partial",
index: event.index ?? 0,
id: callId,
name,
arguments: args,
}
}
return
}
Expand All @@ -1164,6 +1219,16 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
if (event?.type === "response.output_item.added" || event?.type === "response.output_item.done") {
const item = event?.item
if (item) {
// Capture tool identity so subsequent argument deltas can be attributed.
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 || item.function_name
if (typeof callId === "string" && callId.length > 0) {
this.pendingToolCallId = callId
this.pendingToolCallName = typeof name === "string" ? name : undefined
}
}

if (item.type === "text" && item.text) {
yield { type: "text", text: item.text }
} else if (item.type === "reasoning" && item.text) {
Expand Down
Loading