mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
* fix(responses): close namespace round-trip for Responses-Chat translation (#7936) The #7905 custom-tool-call path landed in release/v3.8.49 but left #7936 open: Responses namespace sub-tools were flattened to a bare leaf on the Chat wire with no response-side closure, so Codex's adjudicator rejected every namespace sub-tool call with `unsupported call` -- it only has a dispatch entry for the header bits (namespace+name), no entry for the bare leaf. This patch closes the round-trip without mutating the Chat wire name (alignment with #7905's bare-leaf contract and with the issue author's proposed fix): * request side (openai-responses.ts): keep tool.function.name as the bare leaf, populate a side-band namespaceToolIdentityMap keyed on that leaf, and thread it through translatedBody._toolNameMap. * request -> response seam (chatCore.ts): extract the identity map before dispatch and pass it through to the non-stream completion path and to all three stream pipelines (translate openai-responses, translate other, passthrough). * response translator (response/openai-responses.ts): in emitToolCall (response.output_item.added) and closeToolCall (custom_tool_call / function_call output_item.done), call resolveRequestToolIdentity() to rewrite the bare leaf back to {namespace,name} and emit codex-compatible independent fields. * passthrough (utils/stream.ts): add a response passthrough rewriter restoreResponsesPassthroughFunctionCallIdentity that intercepts response.output_item.added, response.output_item.done, and response.completed and stamps the same {namespace,name} tuple. * helper (requestToolIdentity.ts): a 20-line stateless resolver; never parses a name. The wire-visible Chat tool.function.name stays the bare leaf -- non-OpenAI providers (NVIDIA, GLM, Kimi, Gemini, ...) frequently truncate or rewrite long __-dotted names; bare leaves avoid that failure mode entirely. The codex ResponseItem::FunctionCall schema (models.rs) declares an independent namespace: Option<String> field and has a function_call_deserializes_optional_namespace round-trip test, so emitting it separately matches the codex adjudicator dispatch. Includes 19 new test cases across 4 files: - request-side bare-leaf wire + side-band ledger construction - response-side tuple emit + unmapped passthrough + apply_patch exclusion - ambiguous-leaf collision safety (entry dropped, leaf emits verbatim) - per-request isolation between concurrent streams - a precompiled Atlassian-style nested namespace override * fix(responses): skip tool_search_call input items instead of 400 (#7936 addendum) Codex 0.42+ emits `tool_search_call` (and later `tool_search_result`) input items when the model uses the dynamic tool-search optimization. They are metadata-only: they record that the model queried a subset of the available tools, and carry nothing that OpenAI Chat Completions can represent. Without an explicit skip in openai-responses.ts, the input loop threw Unsupported Responses API feature: input item type 'tool_search_call' cannot be represented in Chat Completions -- and because these items stay in the Responses API `input` for every follow-up turn, the whole server returned 400 on EVERY subsequent /v1/responses in the same session until the user cleared history. Observed in the wild: /v1/responses 400 "Unsupported Responses API feature: input item type 'tool_search_call' cannot be represented in Chat Completions [longcat/LongCat-2.0 (400), longcat/LongCat-2.0 (400)]" The meituan combo (longcat fallback) was the most visible victim, but the underlying throw is source-format-side and hits any Responses-API consumer whose upstream does not natively support Responses. Fix: stop on the item type the same way `reasoning` is skipped -- display-only metadata, no chat side-effect. Covers both `tool_search_call` and the follow-up `tool_search_result` shapes. Adds 3 unit tests: - tool_search_call is silently skipped (no 400) - tool_search_result is silently skipped - tool_search_call items interspersed with real messages are skipped in order; real messages survive * chore(quality): rebaseline openai-responses.ts + stream.ts own-growth (#7936 namespace round-trip) --------- Co-authored-by: TonPro <hello@tonpro.fu> Co-authored-by: RCrushMe <RCrushMe@users.noreply.github.com>
2 lines
5.2 KiB
TypeScript
2 lines
5.2 KiB
TypeScript
// @ts-nocheckimport test from "node:test";import assert from "node:assert/strict";const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");const originalFetch = globalThis.fetch;type ToolItem = { type: string; namespace?: string; name?: string };type SseFrame = { event: string | undefined; data: unknown };function requestBody(stream: boolean) { return { model: "gpt-4o-mini", stream, input: [ { type: "additional_tools", tools: [ { type: "namespace", name: "mcp__atlassian__cloud__tenant", tools: [{ name: "read_issue", parameters: { type: "object" } }], }, ], }, { type: "message", role: "user", content: [{ type: "input_text", text: "read it" }] }, ], };}function chatToolCall() { return { id: "chatcmpl_namespace_identity", object: "chat.completion", created: 1, model: "gpt-4o-mini", choices: [ { index: 0, message: { role: "assistant", tool_calls: [ { index: 0, id: "call_namespace_identity", type: "function", function: { name: "mcp__atlassian__cloud__tenant__read_issue", arguments: JSON.stringify({ key: "PROJ-1" }), }, }, ], }, finish_reason: "tool_calls", }, ], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, };}function streamingChatToolCall() { const toolCall = chatToolCall().choices[0].message.tool_calls[0]; return [ { id: "chatcmpl_namespace_identity", object: "chat.completion.chunk", created: 1, model: "gpt-4o-mini", choices: [ { index: 0, delta: { role: "assistant", tool_calls: [toolCall] }, finish_reason: "tool_calls", }, ], }, ];}function sse(events: unknown[]) { return ( events.map((event) => "data: " + JSON.stringify(event) + "\n\n").join("") + "data: [DONE]\n\n" );}async function invokeResponsesRequest(stream: boolean) { const calls: Array<{ body: Record<string, unknown> }> = []; globalThis.fetch = async (_url, init = {}) => { const body = JSON.parse(String(init.body)); calls.push({ body }); const payload = stream ? sse(streamingChatToolCall()) : JSON.stringify(chatToolCall()); return new Response(payload, { status: 200, headers: { "Content-Type": stream ? "text/event-stream" : "application/json" }, }); }; try { const body = requestBody(stream); const result = await handleChatCore({ body: structuredClone(body), modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false }, credentials: { apiKey: "sk-test", providerSpecificData: {} }, log: { debug() {}, info() {}, warn() {}, error() {} }, clientRawRequest: { endpoint: "/v1/responses", body: structuredClone(body), headers: new Headers({ accept: stream ? "text/event-stream" : "application/json" }), }, userAgent: "namespace-identity-integration-test", } as never); assert.equal(result.success, true); assert.equal(calls.length, 1); assert.equal( "_toolNameMap" in calls[0].body, false, "identity metadata must not reach upstream" ); return result.response; } finally { globalThis.fetch = originalFetch; }}function parseSseFrames(body: string): SseFrame[] { return body .trim() .split(/\r?\n\r?\n/) .map((frame) => { const fields = frame.split(/\r?\n/); const event = fields.find((line) => line.startsWith("event: "))?.slice("event: ".length); const data = fields.find((line) => line.startsWith("data: "))?.slice("data: ".length); return { event, data: data === "[DONE]" ? data : JSON.parse(data ?? "null") }; });}function assertNamespaceTuple(item: ToolItem | undefined) { assert.ok(item, "expected a function-call output item"); assert.equal(item.type, "function_call"); assert.deepEqual( { namespace: item.namespace, name: item.name }, { namespace: "mcp__atlassian__cloud__tenant", name: "read_issue" } );}test.afterEach(() => { globalThis.fetch = originalFetch;});test("returns a namespace tuple for a non-streaming Responses request translated through chatCore", async () => { const response = await invokeResponsesRequest(false); const payload = await response.json(); assertNamespaceTuple(payload.output.find((item: ToolItem) => item.type === "function_call"));});test("emits namespace tuples for every streaming Responses function-call event translated through chatCore", async () => { const response = await invokeResponsesRequest(true); const frames = parseSseFrames(await response.text()); const added = frames.find((frame) => frame.event === "response.output_item.added"); assertNamespaceTuple((added?.data as { item?: ToolItem })?.item); const done = frames.find((frame) => frame.event === "response.output_item.done"); assertNamespaceTuple((done?.data as { item?: ToolItem })?.item); const completed = frames.find((frame) => frame.event === "response.completed"); assertNamespaceTuple( (completed?.data as { response?: { output?: ToolItem[] } })?.response?.output?.find( (item) => item.type === "function_call" ) );});
|