fix: handle missing tool identity in OpenAI Native streams (#10719)

This commit is contained in:
Hannes Rudolph 2026-01-15 11:42:34 -07:00 committed by GitHub
parent 4ee494b08c
commit f2b16d400d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 161 additions and 21 deletions

View file

@ -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"}',
})
})
})

View file

@ -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),
}),

View file

@ -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<OpenAiCodexHandler["getModel"]>
/**
@ -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 } : {}),
}
@ -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

View file

@ -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<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") {
@ -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) {