Files
OmniRoute/tests/unit/namespace-tool-identity-integration.test.ts
RCrushMe 89bad0fa52 fix(responses): close namespace round-trip for Responses-Chat translation (#7936) (#8151)
* 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>
2026-07-22 17:38:30 -03:00

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" ) );});