mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: handle missing tool identity in OpenAI Native streams (#10719)
This commit is contained in:
parent
4ee494b08c
commit
f2b16d400d
4 changed files with 161 additions and 21 deletions
|
|
@ -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"}',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue