diff --git a/src/api/providers/__tests__/openai-native-tools.spec.ts b/src/api/providers/__tests__/openai-native-tools.spec.ts index 4ce6afd320..b3c0ae0dfe 100644 --- a/src/api/providers/__tests__/openai-native-tools.spec.ts +++ b/src/api/providers/__tests__/openai-native-tools.spec.ts @@ -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"}', + }) + }) }) diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 2f9f0bb9d7..a95ba0a004 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -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), }), @@ -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), }), diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 490b5baaae..8034502c0d 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -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" @@ -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 /** @@ -354,7 +352,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const codexHeaders: Record = { 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 } : {}), } @@ -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) @@ -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 diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 58a62497f7..b028d95c1e 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -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, @@ -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) @@ -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", @@ -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, + }, + }) } private normalizeUsage(usage: any, model: OpenAiNativeModel): ApiStreamUsageChunk | undefined { @@ -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() @@ -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 = { + 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 if (typeof (stream as any)[Symbol.asyncIterator] !== "function") { @@ -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, @@ -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 @@ -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 } @@ -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) {