From bf5aa296a08d3287c03891d20415a18a32d761ce Mon Sep 17 00:00:00 2001 From: yyyr Date: Sun, 12 Jul 2026 10:31:33 +0800 Subject: [PATCH 1/6] fix(responses): accept OpenAI tool_search family artifacts on passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Floway 502'd on any /v1/responses request that carried a member of the OpenAI Responses API tool_search feature family (gpt-5.4+): the store layer's stageInputItem threw a TypeError for the unknown `additional_tools` input item type, and the surrounding protocol had no declared shape for the family's other artifacts either. Fix at the gateway boundary so passthrough is loss-free regardless of any later flatten policy. Protocol (packages/protocols/src/responses/index.ts): - Collect the new input item variants: `additional_tools` (Codex's turn-scoped tool declaration), `program` and `program_output` (Programmatic Tool Calling output items echoed back into input on manual conversation replay). - Extend `ResponsesHostedToolType` with `programmatic_tool_calling`. - Declare `defer_loading?: boolean` and `allowed_callers?: string[]` on `ResponsesFunctionTool` / `ResponsesCustomTool`, and `caller?` on `ResponsesFunctionToolCallItem` / `ResponsesOutputFunctionCall` (set to `{type: 'program'}` on PTC-driven calls). - Extend `ResponsesOutputItem` with the two PTC output-item permissive variants so the streamed lifecycle survives without a native canonicalizer entry. Store (packages/gateway/src/data-plane/chat/responses/items/): - format.ts: register `program: 'pg'` and `program_output: 'pgo'` prefixes so upstream-generated PTC output items can be persisted (and stitched via `previous_response_id`). - store.ts: skip-persist `additional_tools` in `stageInputItem` — it is a turn-scoped developer directive the client re-declares every request, with no cross-turn reader. Mirrors `compaction_trigger`. - format_test.ts: pin the new prefixes and the `additional_tools`-throws-in-prefix-lookup regression. Refs: https://developers.openai.com/api/docs/guides/tools-tool-search https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling --- .../data-plane/chat/responses/items/format.ts | 6 ++ .../chat/responses/items/format_test.ts | 11 +++ .../data-plane/chat/responses/items/store.ts | 7 ++ packages/protocols/src/responses/index.ts | 74 ++++++++++++++++++- 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/gateway/src/data-plane/chat/responses/items/format.ts b/packages/gateway/src/data-plane/chat/responses/items/format.ts index 96c585fed..90ed15067 100644 --- a/packages/gateway/src/data-plane/chat/responses/items/format.ts +++ b/packages/gateway/src/data-plane/chat/responses/items/format.ts @@ -39,6 +39,12 @@ const itemTypePrefixes = { mcp_list_tools: 'mcpl', mcp_approval_request: 'mcpar', mcp_approval_response: 'mcpa', + // Programmatic Tool Calling output items — persisted for `previous_response_id` + // snapshot stitching so the model sees its own prior program execution on + // subsequent turns. See `packages/protocols/src/responses/tool-search-family.ts` + // for the surrounding tool_search family design. + program: 'pg', + program_output: 'pgo', } as const satisfies Record; const knownPrefixes = new Set(Object.values(itemTypePrefixes)); diff --git a/packages/gateway/src/data-plane/chat/responses/items/format_test.ts b/packages/gateway/src/data-plane/chat/responses/items/format_test.ts index 2b6eab71e..012f64f7d 100644 --- a/packages/gateway/src/data-plane/chat/responses/items/format_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/items/format_test.ts @@ -35,6 +35,8 @@ const explicitPrefixes = [ ['mcp_list_tools', 'mcpl'], ['mcp_approval_request', 'mcpar'], ['mcp_approval_response', 'mcpa'], + ['program', 'pg'], + ['program_output', 'pgo'], ] as const; test('accepts the design-spec examples (CRC32 over only the body segment)', () => { @@ -73,6 +75,15 @@ test('rejects compaction_trigger — a control signal that should never be store assertThrows(() => createTemporaryResponsesItemId('compaction_trigger'), TypeError, 'Unknown Responses item type'); }); +// `additional_tools` is a turn-scoped developer directive the client re-sends +// every turn (no cross-turn persistence value). `stageInputItem` filters it +// out before minting a stored row, so — like `compaction_trigger` — no prefix +// exists. The throw pins the invariant. +test('rejects additional_tools — a turn-scoped directive that should never be stored', () => { + assertThrows(() => createStoredResponsesItemId('additional_tools'), TypeError, 'Unknown Responses item type'); + assertThrows(() => createTemporaryResponsesItemId('additional_tools'), TypeError, 'Unknown Responses item type'); +}); + test('successive stored ids for the same item type collide-free under random body', () => { const seen = new Set(); for (let i = 0; i < 1024; i += 1) { diff --git a/packages/gateway/src/data-plane/chat/responses/items/store.ts b/packages/gateway/src/data-plane/chat/responses/items/store.ts index 1987c3c29..ea5eb3797 100644 --- a/packages/gateway/src/data-plane/chat/responses/items/store.ts +++ b/packages/gateway/src/data-plane/chat/responses/items/store.ts @@ -283,6 +283,13 @@ export class LayeredStatefulResponsesStore implements StatefulResponsesStore { // type that never needs one. if (item.type === 'compaction_trigger') return; + // `additional_tools` is a turn-scoped developer directive re-declared + // verbatim by the client on every request (no other client type emits it, + // and Codex always re-sends fresh). Persisting has no reader; skip + // mirrors `compaction_trigger` and keeps `createStoredResponsesItemId` + // from needing a prefix for a type with no storage semantics. + if (item.type === 'additional_tools') return; + if (item.type === 'item_reference') { const row = this.getItemById(item.id); if (row === undefined) throw new Error(`Cannot stage unresolved Responses item_reference id=${item.id}`); diff --git a/packages/protocols/src/responses/index.ts b/packages/protocols/src/responses/index.ts index 0ebb97c9f..5912e4ada 100644 --- a/packages/protocols/src/responses/index.ts +++ b/packages/protocols/src/responses/index.ts @@ -105,7 +105,10 @@ export type ResponsesInputItem = | ResponsesMcpCallItem | ResponsesMcpListToolsItem | ResponsesMcpApprovalRequestItem - | ResponsesMcpApprovalResponseItem; + | ResponsesMcpApprovalResponseItem + | ResponsesInputAdditionalToolsItem + | ResponsesInputProgramItem + | ResponsesInputProgramOutputItem; export interface ResponsesInputMessage { type: 'message'; @@ -147,6 +150,10 @@ export interface ResponsesFunctionToolCallItem { name: string; arguments: string; status: 'completed' | 'in_progress' | 'incomplete'; + // Set when the call was emitted from inside a Programmatic Tool Calling + // program (`{caller: {type: 'program'}}`). Absent on model-direct calls. + // Ref: https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling + caller?: { type: string; [key: string]: unknown }; } export interface ResponsesFunctionCallOutputItem { @@ -298,6 +305,32 @@ export interface ResponsesMcpApprovalResponseItem extends ResponsesPermissiveIte output?: unknown; } +// Official OpenAI Responses API input item introduced with the gpt-5.4+ +// tool_search feature family: declares turn-scoped tools at a specific +// conversation point without mutating persistent `payload.tools[]`. `tools[]` +// mirrors the top-level `tools[]` schema (function / custom / hosted incl. +// `namespace`). The gateway `withFlattenToolSearchFamily` interceptor may +// desugar the whole family to legacy tools[]-only shape when the resolved +// candidate has the `flatten-tool-search-family` flag enabled. +// +// Ref: https://developers.openai.com/api/docs/guides/tools-tool-search +export interface ResponsesInputAdditionalToolsItem + extends ResponsesPermissiveItem<'additional_tools'> { + role?: 'developer'; + tools?: ResponsesTool[]; +} + +// Programmatic Tool Calling (PTC) output items echoed back into a subsequent +// turn's input (manual conversation replay by clients that don't use +// `previous_response_id`). Permissive base — the wire schema carries the +// generated code, program status, and result payload; the gateway treats +// them as opaque and forwards verbatim to upstream on `previous_response_id` +// stitching or client-replay flows. +// +// Ref: https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling +export type ResponsesInputProgramItem = ResponsesPermissiveItem<'program'>; +export type ResponsesInputProgramOutputItem = ResponsesPermissiveItem<'program_output'>; + export interface ResponsesInputImageGenerationCall { type: 'image_generation_call'; id?: string; @@ -314,6 +347,18 @@ export interface ResponsesFunctionTool { parameters: Record; strict: boolean; description?: string; + // `defer_loading: true` on a function tool inside a `namespace` container + // marks it as unavailable at request start; the model must issue a + // `tool_search` call to load its definition. Only meaningful when the + // upstream supports tool_search (gpt-5.4+); forwarded verbatim on native + // paths, stripped by `withFlattenToolSearchFamily` on flag-on legacy paths. + defer_loading?: boolean; + // `allowed_callers: ["programmatic"]` restricts a tool to invocation from + // Programmatic Tool Calling generated code only (never model-direct). + // Forwarded verbatim on native paths, stripped by + // `withFlattenToolSearchFamily` on flag-on legacy paths (so the tool + // becomes freely callable). + allowed_callers?: string[]; } // Codex and other Responses clients ship hosted server tools (web_search, @@ -340,6 +385,7 @@ export type ResponsesHostedToolType = | typeof WEB_SEARCH_HOSTED_TYPE_NAMES[number] | 'image_generation' | 'tool_search' + | 'programmatic_tool_calling' | 'namespace'; export interface ResponsesHostedTool { @@ -371,6 +417,10 @@ export interface ResponsesCustomTool { name: string; description?: string; format?: Record; + // Mirror of `ResponsesFunctionTool.defer_loading` / `allowed_callers` for + // Freeform custom tools; identical semantics. + defer_loading?: boolean; + allowed_callers?: string[]; } export type ResponsesTool = ResponsesFunctionTool | ResponsesHostedTool | ResponsesCustomTool; @@ -458,7 +508,9 @@ export type ResponsesOutputItem = | ResponsesMcpListToolsItem | ResponsesMcpApprovalRequestItem | ResponsesMcpApprovalResponseItem - | ResponsesOutputImageGenerationCall; + | ResponsesOutputImageGenerationCall + | ResponsesOutputProgramItem + | ResponsesOutputProgramOutputItem; export interface ResponsesOutputMessage { type: 'message'; @@ -487,6 +539,9 @@ export interface ResponsesOutputFunctionCall { name: string; arguments: string; status: string; + // Set to `{type: 'program'}` when the call was emitted from inside a + // Programmatic Tool Calling program; absent on model-direct calls. + caller?: { type: string; [key: string]: unknown }; } export interface ResponsesOutputCustomToolCall { @@ -557,6 +612,15 @@ export interface ResponsesOutputImageGenerationCall { error?: { message: string; code: string; type?: string }; } +// Programmatic Tool Calling output items emitted by the upstream during PTC +// execution. Kept as permissive base — the gateway treats the code, program +// status, and results as opaque and forwards them verbatim through canonicalize +// / snapshot storage / client egress. +// +// Ref: https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling +export type ResponsesOutputProgramItem = ResponsesPermissiveItem<'program'>; +export type ResponsesOutputProgramOutputItem = ResponsesPermissiveItem<'program_output'>; + // ── Stream event types ── // Spec marks sequence_number required, but some Copilot upstreams omit it @@ -752,3 +816,9 @@ export { parseResponsesStream, type ParseResponsesStreamOptions } from './stream export { RESPONSES_MISSING_TERMINAL_MESSAGE, collectResponsesProtocolEventsToResult } from './to-result.ts'; export { reassembleResponsesEvents } from './reassemble.ts'; export { responsesProtocolFrameToSSEFrame } from './to-sse.ts'; +export { + NAMESPACE_NAME_DELIMITER, + unpackNamespaceTools, + unprefixNamespaceToolCall, + flattenToolSearchFamilyTools, +} from './tool-search-family.ts'; From a1c6461c45c9d813011a462b74271e09cbfaf457 Mon Sep 17 00:00:00 2001 From: yyyr Date: Sun, 12 Jul 2026 10:31:50 +0800 Subject: [PATCH 2/6] feat(protocols): tool_search family desugar helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce three protocol-level helpers used by both the gateway interceptors and the responses-via-* translators to convert a Responses `tools[]` between the gpt-5.4+ tool_search family shape and a legacy tools[]-only shape: - `unpackNamespaceTools(tools)`: expands hosted `type: 'namespace'` container entries into their nested tools, prefixing each sub-tool `name` with `__`. Preserves the grouping semantic on targets that can't carry a `namespace` container. Constraint: sub-tool names must not contain `__`. - `unprefixNamespaceToolCall(name)`: inverse of the name-prefixing. Splits on the LAST delimiter so a namespace name that itself contains `__` round-trips. - `flattenToolSearchFamilyTools(tools)`: one-pass desugar — drop `tool_search` and `programmatic_tool_calling` entries, unpack namespaces (via `unpackNamespaceTools`), strip `defer_loading` and `allowed_callers` fields on remaining function/custom tools. `NAMESPACE_NAME_DELIMITER = '__'` is exported for callers that need to know the delimiter (e.g. events translators). `__` is the only value that satisfies the Chat Completions function-name regex `^[a-zA-Z0-9_-]{1,64}$` and is uncommon enough in real sub-tool names to keep the split unambiguous. Ref: https://developers.openai.com/api/docs/guides/tools-tool-search --- .../src/responses/tool-search-family.ts | 80 +++++++++ .../src/responses/tool-search-family_test.ts | 161 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 packages/protocols/src/responses/tool-search-family.ts create mode 100644 packages/protocols/src/responses/tool-search-family_test.ts diff --git a/packages/protocols/src/responses/tool-search-family.ts b/packages/protocols/src/responses/tool-search-family.ts new file mode 100644 index 000000000..28dfd651f --- /dev/null +++ b/packages/protocols/src/responses/tool-search-family.ts @@ -0,0 +1,80 @@ +import type { ResponsesTool } from './index.ts'; + +// Delimiter used to encode a `namespace` container's name into its unpacked +// sub-tools' names. Chosen to satisfy the Chat Completions function-name +// regex `^[a-zA-Z0-9_-]{1,64}$` (rules out `.`) while being unlikely to +// collide with real sub-tool names. Constraint: sub-tool names inside a +// namespace MUST NOT contain this delimiter — Codex's own conventions +// (`spawn_agent`, `send_message`, `followup_task`, ...) comply. When a +// legit sub-tool name contains `__`, the reverse-strip on the response +// side will misidentify the split and Codex will not route the tool call. +// Ref: https://developers.openai.com/api/docs/guides/tools-tool-search +export const NAMESPACE_NAME_DELIMITER = '__'; + +// Expand any hosted `type: 'namespace'` container entries into their nested +// `tools[]`, prefixing each sub-tool's `name` with `__` to preserve +// the container's grouping semantic. Preserves order and drops the container +// itself. Leaves non-namespace entries (function, custom, other hosted types) +// untouched. One-level expansion — nested namespaces are not in the OpenAI +// schema; a namespace found inside another namespace's `tools[]` after one +// pass is malformed and passed through untouched for upstream to reject. +export const unpackNamespaceTools = (tools: ResponsesTool[]): ResponsesTool[] => { + const out: ResponsesTool[] = []; + for (const tool of tools) { + if (tool.type !== 'namespace') { + out.push(tool); + continue; + } + const container = tool as { name?: unknown; tools?: unknown }; + if (!Array.isArray(container.tools)) { + out.push(tool); + continue; + } + const nsName = typeof container.name === 'string' ? container.name : null; + for (const sub of container.tools as ResponsesTool[]) { + if (nsName !== null && 'name' in sub && typeof (sub as { name?: unknown }).name === 'string') { + const prefixed = `${nsName}${NAMESPACE_NAME_DELIMITER}${(sub as { name: string }).name}`; + out.push({ ...(sub as object), name: prefixed } as ResponsesTool); + } else { + out.push(sub); + } + } + } + return out; +}; + +// Reverse of `unpackNamespaceTools`'s name prefixing. Splits on the LAST +// delimiter occurrence, so a namespace name that itself contains `__` round- +// trips safely. Returns null when the name doesn't look prefixed; caller +// passes the original name through untouched in that case. +export const unprefixNamespaceToolCall = (name: string): { namespace: string; name: string } | null => { + const idx = name.lastIndexOf(NAMESPACE_NAME_DELIMITER); + if (idx <= 0) return null; + const suffixStart = idx + NAMESPACE_NAME_DELIMITER.length; + if (suffixStart >= name.length) return null; + return { + namespace: name.slice(0, idx), + name: name.slice(suffixStart), + }; +}; + +// One-pass desugaring of the tool_search family features on a Responses +// tools[] to a legacy tools[]-only shape: +// 1. drop `type: 'tool_search'` and `type: 'programmatic_tool_calling'` entries +// 2. unpack `type: 'namespace'` containers via `unpackNamespaceTools` +// (sub-tool names prefixed with `__`) +// 3. strip `defer_loading` and `allowed_callers` fields from remaining +// function/custom tools (they have no meaning without tool_search / PTC) +export const flattenToolSearchFamilyTools = (tools: ResponsesTool[]): ResponsesTool[] => { + const withoutFamilyHosted = tools.filter(t => t.type !== 'tool_search' && t.type !== 'programmatic_tool_calling'); + const unpacked = unpackNamespaceTools(withoutFamilyHosted); + return unpacked.map(stripToolSearchFamilyFields); +}; + +const stripToolSearchFamilyFields = (tool: ResponsesTool): ResponsesTool => { + if (tool.type !== 'function' && tool.type !== 'custom') return tool; + const source = tool as unknown as Record; + if (source.defer_loading === undefined && source.allowed_callers === undefined) return tool; + const { defer_loading: _dl, allowed_callers: _ac, ...rest } = source; + return rest as unknown as ResponsesTool; +}; diff --git a/packages/protocols/src/responses/tool-search-family_test.ts b/packages/protocols/src/responses/tool-search-family_test.ts new file mode 100644 index 000000000..fa829c800 --- /dev/null +++ b/packages/protocols/src/responses/tool-search-family_test.ts @@ -0,0 +1,161 @@ +import { describe, test } from 'vitest'; + +import type { ResponsesTool } from './index.ts'; +import { NAMESPACE_NAME_DELIMITER, flattenToolSearchFamilyTools, unpackNamespaceTools, unprefixNamespaceToolCall } from './tool-search-family.ts'; +import { assert, assertEquals } from '@floway-dev/test-utils'; + +describe('NAMESPACE_NAME_DELIMITER', () => { + test('is `__` — the value load-bearing for round-trip', () => { + assertEquals(NAMESPACE_NAME_DELIMITER, '__'); + }); +}); + +describe('unpackNamespaceTools', () => { + test('expands a namespace into its sub-tools with `__` name prefix, dropping the container', () => { + const tools: ResponsesTool[] = [ + { type: 'function', name: 'top_level', parameters: {}, strict: false }, + { + type: 'namespace', + name: 'collab', + tools: [ + { type: 'function', name: 'spawn_agent', parameters: {}, strict: false }, + { type: 'custom', name: 'exec' }, + ], + } as unknown as ResponsesTool, + { type: 'function', name: 'after', parameters: {}, strict: false }, + ]; + + const out = unpackNamespaceTools(tools); + + assertEquals(out.length, 4); + assertEquals(out[0].type, 'function'); + assertEquals((out[0] as { name: string }).name, 'top_level'); + assertEquals(out[1].type, 'function'); + assertEquals((out[1] as { name: string }).name, 'collab__spawn_agent'); + assertEquals(out[2].type, 'custom'); + assertEquals((out[2] as { name: string }).name, 'collab__exec'); + assertEquals(out[3].type, 'function'); + assertEquals((out[3] as { name: string }).name, 'after'); + }); + + test('drops an empty namespace and passes non-namespace tools through unchanged', () => { + const fn: ResponsesTool = { type: 'function', name: 'keep', parameters: {}, strict: false }; + const emptyNs = { type: 'namespace', name: 'ns', tools: [] } as unknown as ResponsesTool; + + const out = unpackNamespaceTools([fn, emptyNs]); + + assertEquals(out.length, 1); + assertEquals(out[0], fn); + }); + + test('passes malformed namespace (`tools` non-array) through untouched', () => { + const malformed = { type: 'namespace', name: 'ns' } as unknown as ResponsesTool; + + const out = unpackNamespaceTools([malformed]); + + assertEquals(out.length, 1); + assertEquals(out[0], malformed); + }); + + test('leaves non-namespace hosted tools untouched (web_search, tool_search, image_generation, programmatic_tool_calling)', () => { + const tools: ResponsesTool[] = [ + { type: 'web_search' }, + { type: 'image_generation' }, + { type: 'tool_search' }, + { type: 'programmatic_tool_calling' }, + ]; + + const out = unpackNamespaceTools(tools); + + assertEquals(out, tools); + }); + + test('preserves sub-tool without a name field (unusual — passes through, unprefixed)', () => { + const ns = { + type: 'namespace', + name: 'ns', + tools: [{ type: 'web_search' }], + } as unknown as ResponsesTool; + + const out = unpackNamespaceTools([ns]); + + assertEquals(out.length, 1); + assertEquals(out[0], { type: 'web_search' }); + }); +}); + +describe('unprefixNamespaceToolCall', () => { + test('splits `__` at the LAST delimiter — round-trips namespaces that contain `__`', () => { + assertEquals(unprefixNamespaceToolCall('collab__spawn_agent'), { namespace: 'collab', name: 'spawn_agent' }); + assertEquals(unprefixNamespaceToolCall('outer__inner__leaf'), { namespace: 'outer__inner', name: 'leaf' }); + }); + + test('returns null for names that are not prefixed', () => { + assertEquals(unprefixNamespaceToolCall('spawn_agent'), null); + assertEquals(unprefixNamespaceToolCall(''), null); + assertEquals(unprefixNamespaceToolCall('no_delimiter_here'), null); + }); + + test('returns null for degenerate boundary shapes (leading / trailing / bare delimiter)', () => { + assertEquals(unprefixNamespaceToolCall('__leaf'), null); + assertEquals(unprefixNamespaceToolCall('trailing__'), null); + assertEquals(unprefixNamespaceToolCall('__'), null); + }); +}); + +describe('flattenToolSearchFamilyTools', () => { + test('runs the full one-pass desugar: drop tool_search / programmatic_tool_calling, unpack namespace with prefix, strip defer_loading / allowed_callers', () => { + const tools: ResponsesTool[] = [ + { type: 'function', name: 'keep', parameters: {}, strict: false }, + { type: 'tool_search' }, + { type: 'programmatic_tool_calling' }, + { + type: 'namespace', + name: 'collab', + tools: [ + { type: 'function', name: 'spawn_agent', parameters: {}, strict: false, defer_loading: true }, + { type: 'function', name: 'send_message', parameters: {}, strict: false, allowed_callers: ['programmatic'] }, + ], + } as unknown as ResponsesTool, + { type: 'function', name: 'deferred', parameters: {}, strict: false, defer_loading: true, allowed_callers: ['programmatic'] }, + { type: 'custom', name: 'exec' }, + { type: 'web_search' }, + ]; + + const out = flattenToolSearchFamilyTools(tools); + + assertEquals(out.length, 6); + // tool_search and programmatic_tool_calling gone + assert(!out.some(t => t.type === 'tool_search')); + assert(!out.some(t => t.type === 'programmatic_tool_calling')); + // namespace unpacked with prefix + const collabSpawn = out.find(t => (t as { name?: string }).name === 'collab__spawn_agent') as { defer_loading?: boolean } | undefined; + assert(collabSpawn !== undefined); + assertEquals(collabSpawn?.defer_loading, undefined); + const collabSend = out.find(t => (t as { name?: string }).name === 'collab__send_message') as { allowed_callers?: unknown } | undefined; + assert(collabSend !== undefined); + assertEquals(collabSend?.allowed_callers, undefined); + // Top-level function with defer_loading / allowed_callers has both stripped + const deferred = out.find(t => (t as { name?: string }).name === 'deferred') as { defer_loading?: boolean; allowed_callers?: unknown } | undefined; + assert(deferred !== undefined); + assertEquals(deferred?.defer_loading, undefined); + assertEquals(deferred?.allowed_callers, undefined); + // Preserved + assert(out.some(t => t.type === 'function' && (t as { name: string }).name === 'keep')); + assert(out.some(t => t.type === 'custom' && (t as { name: string }).name === 'exec')); + assert(out.some(t => t.type === 'web_search')); + }); + + test('is idempotent on already-flat legacy shape', () => { + const tools: ResponsesTool[] = [ + { type: 'function', name: 'a', parameters: {}, strict: false }, + { type: 'custom', name: 'b' }, + { type: 'web_search' }, + ]; + + const out = flattenToolSearchFamilyTools(tools); + const twice = flattenToolSearchFamilyTools(out); + + assertEquals(twice, out); + }); +}); From e58df0ca02299624bdda7c18c5f2208e15b1b45e Mon Sep 17 00:00:00 2001 From: yyyr Date: Sun, 12 Jul 2026 10:32:07 +0800 Subject: [PATCH 3/6] fix(translate): unpack namespace sub-tools instead of silently dropping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `translateResponsesTools` (responses-via-chat-completions) and `translateTools` (responses-via-messages) narrowed the input tools[] to `function` and `custom` entries and let everything else fall through without action — including `type: 'namespace'` container tools, whose nested sub-tools then disappeared silently. The drop dates back to when no client actually used `namespace`. Codex now packs real functional tools (`spawn_agent`, `send_message`, `followup_task`, …) inside a `collaboration` namespace and ships them via `additional_tools`; routing such a request through a claude-code or ollama candidate quietly lost every namespaced sub-tool, breaking the client's tool-call loop. Fix both translators to run `flattenToolSearchFamilyTools` at the top of the tools loop before narrowing. Namespace containers now expand into flat sub-tools (sub-tool names prefixed `__`, which the response-side unprefixer reverses); the tool_search family scaffolding (`tool_search`, `programmatic_tool_calling` entries; `defer_loading` and `allowed_callers` fields) is stripped in the same pass since no target protocol supports it either. Leaf hosted tools (`web_search`, `image_generation`) still fall through — no sub-tools to expand and no faithful bridge onto Chat / Messages. --- .../responses-via-chat-completions/request.ts | 33 ++++++++----- .../src/responses-via-messages/request.ts | 46 +++++++++++-------- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/packages/translate/src/responses-via-chat-completions/request.ts b/packages/translate/src/responses-via-chat-completions/request.ts index 757a6dfff..7ed89ce9a 100644 --- a/packages/translate/src/responses-via-chat-completions/request.ts +++ b/packages/translate/src/responses-via-chat-completions/request.ts @@ -3,7 +3,7 @@ import { addResponsesReasoningToChatCompletionsProjection, type ChatCompletionsR import { buildCustomToolInputSchema } from '../shared/responses-via/custom-tool-wrap.ts'; import { TranslatorInputError } from '../translator-input-error.ts'; import type { ChatCompletionsPayload, ChatCompletionsMessage, ChatCompletionsTool, ChatCompletionsToolCall } from '@floway-dev/protocols/chat-completions'; -import type { ResponsesPayload, ResponsesTool, ResponsesToolChoice } from '@floway-dev/protocols/responses'; +import { flattenToolSearchFamilyTools, type ResponsesPayload, type ResponsesTool, type ResponsesToolChoice } from '@floway-dev/protocols/responses'; interface AssistantAccumulator { message: ChatCompletionsMessage; @@ -44,19 +44,28 @@ const appendAssistantToolCall = ( }; const translateResponsesTools = (tools: ResponsesTool[] | null | undefined, customToolNames: Set): ChatCompletionsTool[] | undefined => { - // Translated Chat Completions targets do not currently have a faithful - // bridge for hosted/deferred Responses tools (`web_search`, - // `tool_search`, `namespace`, `image_generation`, and future builtin - // names). Native Responses targets receive those entries unchanged; this - // translator narrows to function and Freeform `custom` tools, recording - // the latter in `customToolNames` so the events translator can recover - // the freeform shape on the way back. The shim's web_search - // function tool is in `payload.tools` under its resolved name (the shim - // injects it on every request that uses hosted web_search) and reaches - // here as an ordinary function tool — no special carve-out needed. + // The Chat Completions wire has no analogue for the gpt-5.4+ tool_search + // feature family (hosted `tool_search` / `programmatic_tool_calling` + // entries; `namespace` container groupings; `defer_loading` / + // `allowed_callers` fields). Desugar unconditionally via + // `flattenToolSearchFamilyTools`: hosted family entries are dropped, + // `namespace` containers are expanded into flat sub-tools (with sub-tool + // names prefixed `__` — the response-side events translator + // strips the prefix back, mirroring `withUnprefixNamespaceToolCalls` on + // the native path), and `defer_loading` / `allowed_callers` are stripped. + // + // Leaf hosted tools (`web_search`, `image_generation`) fall through — no + // sub-tools to expand, no faithful bridge onto Chat Completions. The + // shim's web_search function tool arrives here under its resolved name + // as an ordinary function tool (the shim injects it on every request + // that uses hosted web_search) — no special carve-out needed. Freeform + // `custom` tools are wrapped as single-string function tools and their + // names recorded in `customToolNames` so the events translator can + // recover the `custom_tool_call` shape on the way back. + const flat = flattenToolSearchFamilyTools(tools ?? []); const out: ChatCompletionsTool[] = []; - for (const tool of tools ?? []) { + for (const tool of flat) { if (tool.type === 'function') { out.push({ type: 'function', diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index ca3fb487a..487cf325f 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -18,15 +18,16 @@ import { type MessagesUserContentBlock, type MessagesUserMessage, } from '@floway-dev/protocols/messages'; -import type { - ResponsesInputContent, - ResponsesInputImage, - ResponsesInputItem, - ResponsesInputMessage, - ResponsesInputText, - ResponsesPayload, - ResponsesTool, - ResponsesToolChoice, +import { + flattenToolSearchFamilyTools, + type ResponsesInputContent, + type ResponsesInputImage, + type ResponsesInputItem, + type ResponsesInputMessage, + type ResponsesInputText, + type ResponsesPayload, + type ResponsesTool, + type ResponsesToolChoice, } from '@floway-dev/protocols/responses'; interface TranslateResponsesToMessagesOptions { @@ -274,18 +275,25 @@ const translateResponsesInput = async (input: string | ResponsesInputItem[], loa }; const translateTools = (tools: ResponsesTool[] | null | undefined, customToolNames: Set): MessagesTool[] | undefined => { - // Translated Messages targets do not currently have a faithful bridge - // for hosted/deferred Responses tools (`web_search`, `tool_search`, - // `namespace`, `image_generation`, and future builtin names). Native - // Responses targets receive those entries unchanged; this translator - // narrows to function and Freeform `custom` tools, recording the latter - // in `customToolNames` so the events translator can reverse the wrap. - // The shim's web_search function tool reaches this code under - // its resolved name as an ordinary function tool — no special carve-out - // needed. + // The Messages wire has no analogue for the gpt-5.4+ tool_search feature + // family (hosted `tool_search` / `programmatic_tool_calling` entries; + // `namespace` container groupings; `defer_loading` / `allowed_callers` + // fields). Desugar unconditionally via `flattenToolSearchFamilyTools`: + // hosted family entries are dropped, `namespace` containers are expanded + // into flat sub-tools (with sub-tool names prefixed `__` — the + // response-side events translator strips the prefix back), and + // `defer_loading` / `allowed_callers` are stripped. + // + // Leaf hosted tools (`web_search`, `image_generation`) fall through — no + // sub-tools to expand, no faithful bridge onto Messages. The shim's + // web_search function tool reaches this code under its resolved name as + // an ordinary function tool. Freeform `custom` tools are wrapped as + // single-string function tools and their names recorded in + // `customToolNames` so the events translator can reverse the wrap. + const flat = flattenToolSearchFamilyTools(tools ?? []); const out: MessagesTool[] = []; - for (const tool of tools ?? []) { + for (const tool of flat) { if (tool.type === 'function') { out.push({ name: tool.name, From ea983409dff7f87fcad8e4187ec0521f842b76a7 Mon Sep 17 00:00:00 2001 From: yyyr Date: Sun, 12 Jul 2026 10:32:24 +0800 Subject: [PATCH 4/6] feat(flags): flatten-tool-search-family for legacy upstreams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register a new operator-toggleable flag that desugars the gpt-5.4+ tool_search family (additional_tools + namespace + defer_loading + tool_search + programmatic_tool_calling + allowed_callers) into a legacy tools[]-only shape before dispatch. Consumed by the pair of request/response interceptors added in a follow-up commit. Provider defaults (exhaustive FlagDefaults record forces each provider to declare a value): - codex / copilot / azure / custom: false — these upstreams either speak native OpenAI Responses (Codex backend; Azure OpenAI) or proxy it in a way that inherits gpt-5.4+ tool_search support (Copilot); Custom is assumed OpenAI-compatible and operators override on a case-by-case basis for legacy proxies. - ollama / claude-code: true — Ollama has its own protocol and claude-code translates through Anthropic Messages; without the desugar the translator would either drop the family or throw on the additional_tools input item. The two hard-required upstream populations (family fully supported vs family fully absent) map cleanly onto the flag off vs flag on states; splitting the six operations into orthogonal flags would create unreachable parameter combinations, so they share one flag. --- packages/provider-azure/src/defaults.ts | 1 + packages/provider-claude-code/src/defaults.ts | 1 + packages/provider-codex/src/defaults.ts | 1 + packages/provider-copilot/src/defaults.ts | 1 + packages/provider-custom/src/defaults.ts | 1 + packages/provider-ollama/src/defaults.ts | 1 + packages/provider/src/flags.ts | 5 +++++ 7 files changed, 11 insertions(+) diff --git a/packages/provider-azure/src/defaults.ts b/packages/provider-azure/src/defaults.ts index b08d1045a..391f70b14 100644 --- a/packages/provider-azure/src/defaults.ts +++ b/packages/provider-azure/src/defaults.ts @@ -15,4 +15,5 @@ export const AZURE_DEFAULT_FLAGS: FlagDefaults = { 'demote-developer-to-system': false, 'strip-billing-attribution': true, 'strip-prompt-cache-key': false, + 'flatten-tool-search-family': false, }; diff --git a/packages/provider-claude-code/src/defaults.ts b/packages/provider-claude-code/src/defaults.ts index eedf964f1..af15d4f48 100644 --- a/packages/provider-claude-code/src/defaults.ts +++ b/packages/provider-claude-code/src/defaults.ts @@ -27,4 +27,5 @@ export const CLAUDE_CODE_DEFAULT_FLAGS: FlagDefaults = { 'demote-developer-to-system': false, 'strip-billing-attribution': false, 'strip-prompt-cache-key': false, + 'flatten-tool-search-family': true, }; diff --git a/packages/provider-codex/src/defaults.ts b/packages/provider-codex/src/defaults.ts index 380262a78..642328801 100644 --- a/packages/provider-codex/src/defaults.ts +++ b/packages/provider-codex/src/defaults.ts @@ -14,4 +14,5 @@ export const CODEX_DEFAULT_FLAGS: FlagDefaults = { 'demote-developer-to-system': false, 'strip-billing-attribution': true, 'strip-prompt-cache-key': false, + 'flatten-tool-search-family': false, }; diff --git a/packages/provider-copilot/src/defaults.ts b/packages/provider-copilot/src/defaults.ts index b941941ec..bc0514fbf 100644 --- a/packages/provider-copilot/src/defaults.ts +++ b/packages/provider-copilot/src/defaults.ts @@ -24,6 +24,7 @@ export const COPILOT_DEFAULT_FLAGS: FlagDefaults = { 'demote-developer-to-system': false, 'strip-billing-attribution': true, 'strip-prompt-cache-key': false, + 'flatten-tool-search-family': false, }; // True when the model id names a Claude release whose Anthropic Messages diff --git a/packages/provider-custom/src/defaults.ts b/packages/provider-custom/src/defaults.ts index 99947065a..03e117d38 100644 --- a/packages/provider-custom/src/defaults.ts +++ b/packages/provider-custom/src/defaults.ts @@ -22,4 +22,5 @@ export const CUSTOM_DEFAULT_FLAGS: FlagDefaults = { // does not pollute the OpenAI-compatible upstream's prompt-cache key. 'strip-billing-attribution': true, 'strip-prompt-cache-key': false, + 'flatten-tool-search-family': false, }; diff --git a/packages/provider-ollama/src/defaults.ts b/packages/provider-ollama/src/defaults.ts index 5e2fda4ff..6032d3966 100644 --- a/packages/provider-ollama/src/defaults.ts +++ b/packages/provider-ollama/src/defaults.ts @@ -14,4 +14,5 @@ export const OLLAMA_DEFAULT_FLAGS: FlagDefaults = { 'demote-developer-to-system': false, 'strip-billing-attribution': true, 'strip-prompt-cache-key': false, + 'flatten-tool-search-family': true, }; diff --git a/packages/provider/src/flags.ts b/packages/provider/src/flags.ts index aaa5dc97c..02c20e6ca 100644 --- a/packages/provider/src/flags.ts +++ b/packages/provider/src/flags.ts @@ -96,6 +96,11 @@ export const OPTIONAL_FLAGS = [ label: 'Strip prompt_cache_key from request', description: 'Drop the top-level `prompt_cache_key` field from Chat Completions and Responses requests before forwarding upstream. Pick this when the upstream rejects `prompt_cache_key` as an unknown argument (e.g. Azure DeepSeek). OpenAI-native and truly OpenAI-compatible upstreams accept the field for prefix-cache attribution, so this stays off by default.', }, + { + id: 'flatten-tool-search-family', + label: 'Flatten tool_search family to legacy shape', + description: "Desugar the gpt-5.4+ Responses API tool_search feature family into a legacy tools[]-only shape before dispatch: move every `type: 'additional_tools'` input item's `tools[]` into top-level `payload.tools[]` and remove the item; unpack any `type: 'namespace'` container into its nested tools (sub-tool names prefixed `__`); drop `type: 'tool_search'` and `type: 'programmatic_tool_calling'` entries; strip `defer_loading` and `allowed_callers` fields from remaining function/custom tools. Enable for upstreams that do not implement the family (translated Chat Completions / Messages targets; Responses endpoints stuck below gpt-5.4). Loses `defer_loading` eager-vs-lazy semantics, PTC orchestration, and `custom_tool_call.namespace` round-trip fidelity.", + }, ] as const satisfies readonly Flag[]; export type FlagId = (typeof OPTIONAL_FLAGS)[number]['id']; From 29c49de758950051c3348b681e70c16d6327cd90 Mon Sep 17 00:00:00 2001 From: yyyr Date: Sun, 12 Jul 2026 10:32:44 +0800 Subject: [PATCH 5/6] feat(responses): flatten-tool-search-family request/response interceptors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the `flatten-tool-search-family` flag into the responses interceptor chain with a pair of complementary interceptors. Request-side `withFlattenToolSearchFamily`: - move every `additional_tools` input item's `tools[]` into top-level `payload.tools[]` and remove the item from `payload.input`; - apply `flattenToolSearchFamilyTools` on the merged tools[] to drop `tool_search` / `programmatic_tool_calling` entries, unpack `namespace` containers (sub-tool names prefixed `__`), and strip `defer_loading` / `allowed_callers` fields. Response-side `withUnprefixNamespaceToolCalls`: - stream-rewrite `function_call` and `custom_tool_call` output items (both on `response.output_item.added/.done` and inside `response.completed/.incomplete/.failed` envelopes) to strip the `__` prefix off `name`; on `custom_tool_call`, the stripped prefix is moved into the `namespace` field. - Since dispatchResponses is the terminal of the responses interceptor chain, the same interceptor handles both native Responses upstreams and the translated Chat/Messages paths — by the time it runs the events are already Responses-shaped. Ordering: - flatten runs BEFORE demote-developer-to-system so the demote step only sees message items with `role: 'developer'`, never the additional_tools directive that also carries that role. - unprefix runs adjacent to (and just outside) canonicalize-output- items, so id/encrypted_content pinning finishes before name rewriting on the same stream. --- .../flatten-tool-search-family.ts | 72 ++++++++++ .../flatten-tool-search-family_test.ts | 123 ++++++++++++++++ .../chat/responses/interceptors/index.ts | 17 +++ .../unprefix-namespace-tool-calls.ts | 87 +++++++++++ .../unprefix-namespace-tool-calls_test.ts | 136 ++++++++++++++++++ 5 files changed, 435 insertions(+) create mode 100644 packages/gateway/src/data-plane/chat/responses/interceptors/flatten-tool-search-family.ts create mode 100644 packages/gateway/src/data-plane/chat/responses/interceptors/flatten-tool-search-family_test.ts create mode 100644 packages/gateway/src/data-plane/chat/responses/interceptors/unprefix-namespace-tool-calls.ts create mode 100644 packages/gateway/src/data-plane/chat/responses/interceptors/unprefix-namespace-tool-calls_test.ts diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/flatten-tool-search-family.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/flatten-tool-search-family.ts new file mode 100644 index 000000000..b40a87bec --- /dev/null +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/flatten-tool-search-family.ts @@ -0,0 +1,72 @@ +// Desugar the gpt-5.4+ Responses API tool_search feature family into a legacy +// tools[]-only shape before dispatch. Always-attached; flag-gated by +// `flatten-tool-search-family`. +// +// The family features are correlated (all released together as one bundle and +// depend on each other): implementing this flag as a single one-pass rewrite +// matches every real upstream population — either the endpoint supports the +// whole family (leave verbatim, flag off) or it supports none of it (desugar +// the entire family in one place, flag on). +// +// Outbound (request → upstream): +// +// 1. Every `type: 'additional_tools'` input item is removed; its inner +// `tools[]` is appended to the top-level `payload.tools[]`. +// 2. Every `type: 'namespace'` container in the resulting `payload.tools[]` +// is unpacked into its nested sub-tools (via `unpackNamespaceTools`, +// which also prefixes each sub-tool's `name` with `__` to +// preserve grouping semantic — see `withUnprefixNamespaceToolCalls` for +// the response-side inverse). +// 3. Every remaining `type: 'tool_search'` or `type: 'programmatic_tool_calling'` +// hosted-tool entry is dropped from `payload.tools[]`. +// 4. Every remaining `function`/`custom` tool has its `defer_loading` and +// `allowed_callers` fields stripped (they have no meaning without +// `tool_search` / PTC). +// +// Inbound: nothing — see `withUnprefixNamespaceToolCalls` for the response-side +// pair that undoes the namespace-name prefix on tool-call outputs. +// +// Ref: https://developers.openai.com/api/docs/guides/tools-tool-search + +import type { ResponsesInterceptor } from './types.ts'; +import { flattenToolSearchFamilyTools, type ResponsesInputItem, type ResponsesTool } from '@floway-dev/protocols/responses'; +import { providerModelOf } from '@floway-dev/provider'; + +export const withFlattenToolSearchFamily: ResponsesInterceptor = async (ctx, _request, run) => { + if (!providerModelOf(ctx.candidate).enabledFlags.has('flatten-tool-search-family')) return await run(); + + const originalInput = ctx.payload.input; + const extracted: ResponsesTool[] = []; + const remainingInput: ResponsesInputItem[] = []; + for (const item of originalInput) { + if (item.type === 'additional_tools') { + const inner = (item as { tools?: unknown }).tools; + if (Array.isArray(inner)) extracted.push(...(inner as ResponsesTool[])); + continue; + } + remainingInput.push(item); + } + + const originalTools = ctx.payload.tools ?? null; + const merged = extracted.length > 0 + ? [...(originalTools ?? []), ...extracted] + : (originalTools ?? []); + const flat = merged.length > 0 ? flattenToolSearchFamilyTools(merged) : []; + + const inputChanged = remainingInput.length !== originalInput.length; + // Reference-equality on the array + members catches "identical shape" — if + // no member differs and the length matches the original tools list, the + // rewrite would be a no-op. + const toolsChanged = + (originalTools === null && flat.length > 0) + || (originalTools !== null && (flat.length !== originalTools.length || flat.some((t, i) => t !== originalTools[i]))); + + if (!inputChanged && !toolsChanged) return await run(); + + ctx.payload = { + ...ctx.payload, + ...(inputChanged ? { input: remainingInput } : {}), + ...(toolsChanged ? { tools: flat.length > 0 ? flat : null } : {}), + }; + return await run(); +}; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/flatten-tool-search-family_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/flatten-tool-search-family_test.ts new file mode 100644 index 000000000..f4f583c12 --- /dev/null +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/flatten-tool-search-family_test.ts @@ -0,0 +1,123 @@ +import { test } from 'vitest'; + +import { withFlattenToolSearchFamily } from './flatten-tool-search-family.ts'; +import type { ResponsesInvocation } from './types.ts'; +import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { doneFrame } from '@floway-dev/protocols/common'; +import { eventResult, type FlagId } from '@floway-dev/provider'; +import { assert, assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; +import type { CanonicalResponsesPayload } from '@floway-dev/translate/via-responses/responses-items'; + +const stubCtx = mockChatGatewayCtx(); + +const okEvents = () => + Promise.resolve( + eventResult( + (async function* () { + yield doneFrame(); + })(), + testTelemetryModelIdentity, + ), + ); + +const invocation = (payload: CanonicalResponsesPayload, enabledFlags: ReadonlySet = new Set(['flatten-tool-search-family'])): ResponsesInvocation => ({ + payload, + candidate: stubModelCandidate({ enabledFlags }), + targetApi: 'responses', + headers: new Headers(), + action: 'generate', +}); + +test('runs the full one-pass desugar when flag is on', async () => { + const input = invocation({ + model: 'gpt-5.6-sol', + input: [ + { type: 'message', role: 'user', content: 'hi' }, + { + type: 'additional_tools', + role: 'developer', + tools: [ + { type: 'custom', name: 'exec' }, + { + type: 'namespace', + name: 'collab', + tools: [ + { type: 'function', name: 'spawn_agent', parameters: {}, strict: false, defer_loading: true }, + ], + }, + ], + } as unknown as CanonicalResponsesPayload['input'][number], + { type: 'message', role: 'user', content: 'go' }, + ], + tools: [ + { type: 'function', name: 'keep', parameters: {}, strict: false, allowed_callers: ['programmatic'] }, + { type: 'tool_search' }, + { type: 'programmatic_tool_calling' }, + ], + }); + + await withFlattenToolSearchFamily(input, stubCtx, okEvents); + + // additional_tools item removed from input; user messages preserved in order + const items = input.payload.input; + assertEquals(items.length, 2); + assertEquals(items[0].type, 'message'); + assertEquals(items[1].type, 'message'); + + // tools[]: [keep (allowed_callers stripped), tool_search dropped, PTC dropped, + // exec (from additional_tools), collab__spawn_agent (unpacked, defer_loading stripped)] + const tools = input.payload.tools ?? []; + assertEquals(tools.length, 3); + const keep = tools.find(t => t.type === 'function' && (t as { name: string }).name === 'keep') as { allowed_callers?: unknown } | undefined; + assert(keep !== undefined); + assertEquals(keep?.allowed_callers, undefined); + assert(tools.some(t => t.type === 'custom' && (t as { name: string }).name === 'exec')); + const collab = tools.find(t => (t as { name?: string }).name === 'collab__spawn_agent') as { defer_loading?: boolean } | undefined; + assert(collab !== undefined); + assertEquals(collab?.defer_loading, undefined); + assert(!tools.some(t => t.type === 'tool_search')); + assert(!tools.some(t => t.type === 'programmatic_tool_calling')); +}); + +test('early-returns when flag is off — payload untouched', async () => { + const originalInput = [ + { + type: 'additional_tools', + role: 'developer', + tools: [{ type: 'function', name: 'spawn_agent', parameters: {}, strict: false }], + }, + { type: 'message', role: 'user', content: 'hi' }, + ]; + const originalTools = [ + { type: 'namespace', name: 'collab', tools: [{ type: 'function', name: 'foo', parameters: {}, strict: false }] }, + ]; + const input = invocation( + { + model: 'gpt-5.6-sol', + input: originalInput as unknown as CanonicalResponsesPayload['input'], + tools: originalTools as unknown as CanonicalResponsesPayload['tools'], + }, + new Set(), + ); + + await withFlattenToolSearchFamily(input, stubCtx, okEvents); + + assertEquals(input.payload.input, originalInput); + assertEquals(input.payload.tools, originalTools); +}); + +test('no-op when flag is on but payload carries no tool_search family artifacts', async () => { + const input = invocation({ + model: 'gpt-5.6-sol', + input: [{ type: 'message', role: 'user', content: 'hi' }], + tools: [{ type: 'function', name: 'plain', parameters: {}, strict: false }], + }); + const originalInput = input.payload.input; + const originalTools = input.payload.tools; + + await withFlattenToolSearchFamily(input, stubCtx, okEvents); + + // Reference-identity preserved when the interceptor decides there's nothing to change. + assertEquals(input.payload.input, originalInput); + assertEquals(input.payload.tools, originalTools); +}); diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/index.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/index.ts index 7a009b2fe..3e7536613 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/index.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/index.ts @@ -3,12 +3,14 @@ import { withResponsesCompactShim } from './compact-shim.ts'; import { withDemoteDeveloperToSystem } from './demote-developer-to-system.ts'; import { withInterleavedSystemDemotedToUser } from './demote-interleaved-system-to-user.ts'; import { withReasoningDisabledOnForcedToolChoice } from './disable-reasoning-on-forced-tool-choice.ts'; +import { withFlattenToolSearchFamily } from './flatten-tool-search-family.ts'; import { withCyberPolicyRetried } from './retry-cyber-policy.ts'; import { withResponsesServerToolShim } from './server-tool-shim.ts'; import { imageGenerationServerTool } from './server-tools/image-generation.ts'; import { webSearchServerTool } from './server-tools/web-search.ts'; import { withPromptCacheKeyStripped } from './strip-prompt-cache-key.ts'; import type { ResponsesInterceptor } from './types.ts'; +import { withUnprefixNamespaceToolCalls } from './unprefix-namespace-tool-calls.ts'; import { withVendorDeepseekResponsesNormalize } from './vendor-deepseek-normalize.ts'; import { withVendorQwenResponsesNormalize } from './vendor-qwen-normalize.ts'; @@ -27,6 +29,14 @@ import { withVendorQwenResponsesNormalize } from './vendor-qwen-normalize.ts'; // - withCyberPolicyRetried: gated by `retry-cyber-policy`. // - withReasoningDisabledOnForcedToolChoice: gated by // `disable-reasoning-on-forced-tool-choice`. +// - withFlattenToolSearchFamily: gated by `flatten-tool-search-family`. +// Runs before `withDemoteDeveloperToSystem` because the `additional_tools` +// input items it drops carry `role: 'developer'`; leaving them in place +// would have the role demoter needlessly rewrite them to `role: 'system'` +// right before this interceptor deletes them anyway. Also runs before +// server-tool-shim's ReAct loop so the shim sees the already-desugared +// tools list (it can't do anything sensible with a `namespace` container +// or a `programmatic_tool_calling` entry). // - withDemoteDeveloperToSystem: gated by `demote-developer-to-system`. // Runs before withInterleavedSystemDemotedToUser so when both flags are // on, a `developer` role first lands as `system`, then any system that @@ -52,6 +62,11 @@ import { withVendorQwenResponsesNormalize } from './vendor-qwen-normalize.ts'; // view and the terminal `response.completed` envelope; downstream // consumers (server-tool shim, storage layer's id mapper, replay // affinity) then see a single canonical pair per item. +// - withUnprefixNamespaceToolCalls: gated by `flatten-tool-search-family`. +// Response-side pair of withFlattenToolSearchFamily. Placed adjacent to +// the canonical-output-items step (both operate on the raw stream); runs +// just outside the canonicalizer so id/encrypted-content pinning +// completes before name rewriting. export const responsesInterceptors: readonly ResponsesInterceptor[] = [ withResponsesCompactShim, withResponsesServerToolShim([ @@ -60,10 +75,12 @@ export const responsesInterceptors: readonly ResponsesInterceptor[] = [ ]), withCyberPolicyRetried, withReasoningDisabledOnForcedToolChoice, + withFlattenToolSearchFamily, withDemoteDeveloperToSystem, withInterleavedSystemDemotedToUser, withPromptCacheKeyStripped, withVendorDeepseekResponsesNormalize, withVendorQwenResponsesNormalize, + withUnprefixNamespaceToolCalls, withResponsesOutputItemsCanonicalized, ]; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/unprefix-namespace-tool-calls.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/unprefix-namespace-tool-calls.ts new file mode 100644 index 000000000..6cbd5e034 --- /dev/null +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/unprefix-namespace-tool-calls.ts @@ -0,0 +1,87 @@ +// Response-side inverse of `withFlattenToolSearchFamily`'s namespace-name +// prefixing. When the request-side flag is on, `unpackNamespaceTools` +// rewrites each namespaced sub-tool's `name` to `__`; the +// model, seeing that prefix, will emit tool_call events under the prefixed +// name too. This interceptor scans the downstream event stream and rewrites +// `function_call` / `custom_tool_call` output items back to their bare names +// (and, for `custom_tool_call`, populates the `namespace` field from the +// stripped prefix) so downstream clients (Codex / SDK consumers) can match +// against their originally-declared tool registry. +// +// Applied to: +// - `response.output_item.added` +// - `response.output_item.done` +// - `response.completed` / `.incomplete` / `.failed` — the terminal +// envelope's `response.output[]` list. +// +// Function-call vs custom-tool-call asymmetry: +// - `function_call` has no `namespace` field in the Responses schema +// (`ResponsesFunctionToolCallItem`/`ResponsesOutputFunctionCall`). Prefix +// is stripped from `name`; namespace context is lost, but the client's +// tool registry is keyed by bare name so routing still works. +// - `custom_tool_call` has `namespace` (see `ResponsesCustomToolCallItem`). +// Prefix is moved from `name` into `namespace`, fully round-tripped. + +import type { ResponsesInterceptor } from './types.ts'; +import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { unprefixNamespaceToolCall, type ResponsesOutputItem, type ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { type ExecuteResult, eventResult } from '@floway-dev/provider'; +import { providerModelOf } from '@floway-dev/provider'; + +const unprefixOutputItem = (item: ResponsesOutputItem): ResponsesOutputItem => { + if (item.type !== 'function_call' && item.type !== 'custom_tool_call') return item; + const name = (item as { name?: unknown }).name; + if (typeof name !== 'string') return item; + const split = unprefixNamespaceToolCall(name); + if (split === null) return item; + if (item.type === 'custom_tool_call') { + return { ...item, name: split.name, namespace: split.namespace }; + } + return { ...item, name: split.name }; +}; + +const unprefixNamespaceToolCalls = async function* ( + frames: AsyncIterable>, +): AsyncGenerator> { + for await (const frame of frames) { + if (frame.type !== 'event') { + yield frame; + continue; + } + const event = frame.event; + + if (event.type === 'response.output_item.added' || event.type === 'response.output_item.done') { + const rewritten = unprefixOutputItem(event.item); + if (rewritten !== event.item) { + yield eventFrame({ ...event, item: rewritten }); + continue; + } + } else if (event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed') { + const originalOutput = event.response.output; + let any = false; + const output = originalOutput.map(item => { + const rewritten = unprefixOutputItem(item); + if (rewritten !== item) any = true; + return rewritten; + }); + if (any) { + yield eventFrame({ ...event, response: { ...event.response, output } }); + continue; + } + } + + yield frame; + } +}; + +export const withUnprefixNamespaceToolCalls: ResponsesInterceptor = async (ctx, _request, run) => { + if (!providerModelOf(ctx.candidate).enabledFlags.has('flatten-tool-search-family')) return await run(); + const result: ExecuteResult> = await run(); + if (result.type !== 'events') return result; + + return eventResult(unprefixNamespaceToolCalls(result.events), result.modelIdentity, { + performance: result.performance, + finalMetadata: result.finalMetadata, + headers: result.headers, + }); +}; diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/unprefix-namespace-tool-calls_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/unprefix-namespace-tool-calls_test.ts new file mode 100644 index 000000000..87a47441b --- /dev/null +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/unprefix-namespace-tool-calls_test.ts @@ -0,0 +1,136 @@ +import { test } from 'vitest'; + +import type { ResponsesInvocation } from './types.ts'; +import { withUnprefixNamespaceToolCalls } from './unprefix-namespace-tool-calls.ts'; +import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts'; +import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import type { ResponsesOutputFunctionCall, ResponsesOutputCustomToolCall, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { type ExecuteResult, eventResult, type FlagId } from '@floway-dev/provider'; +import { assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; +import type { CanonicalResponsesPayload } from '@floway-dev/translate/via-responses/responses-items'; + +const stubCtx = mockChatGatewayCtx(); + +const invocation = (enabledFlags: ReadonlySet = new Set(['flatten-tool-search-family'])): ResponsesInvocation => ({ + payload: { model: 'gpt-test', input: [{ type: 'message' as const, role: 'user' as const, content: 'hi' }] } as CanonicalResponsesPayload, + action: 'generate', + candidate: stubModelCandidate({ enabledFlags }), + targetApi: 'responses', + headers: new Headers(), +}); + +const functionCall = (name: string): ResponsesOutputFunctionCall => ({ + type: 'function_call', + id: 'fc_1', + call_id: 'call_1', + name, + arguments: '{}', + status: 'completed', +}); + +const customToolCall = (name: string, namespace?: string): ResponsesOutputCustomToolCall => ({ + type: 'custom_tool_call', + id: 'ctc_1', + call_id: 'call_2', + name, + input: 'freeform', + ...(namespace !== undefined ? { namespace } : {}), +}); + +const runStream = (events: readonly ResponsesStreamEvent[]) => + (): Promise>> => + Promise.resolve(eventResult( + (async function* () { + for (const event of events) yield eventFrame(event); + })(), + testTelemetryModelIdentity, + )); + +const collect = async (events: AsyncIterable>) => { + const out: ResponsesStreamEvent[] = []; + for await (const frame of events) if (frame.type === 'event') out.push(frame.event); + return out; +}; + +test('strips __ prefix on function_call output items (no namespace field on the schema)', async () => { + const inputEvent: ResponsesStreamEvent = { + type: 'response.output_item.done', + output_index: 0, + item: functionCall('collab__spawn_agent'), + }; + const res = await withUnprefixNamespaceToolCalls(invocation(), stubCtx, runStream([inputEvent])); + if (res.type !== 'events') throw new Error('expected events'); + const [event] = await collect(res.events); + if (event.type !== 'response.output_item.done') throw new Error('expected output_item.done'); + const item = event.item as ResponsesOutputFunctionCall; + assertEquals(item.name, 'spawn_agent'); + assertEquals((item as unknown as { namespace?: string }).namespace, undefined); +}); + +test('strips prefix and populates `namespace` on custom_tool_call output items', async () => { + const inputEvent: ResponsesStreamEvent = { + type: 'response.output_item.done', + output_index: 0, + item: customToolCall('collab__exec_custom'), + }; + const res = await withUnprefixNamespaceToolCalls(invocation(), stubCtx, runStream([inputEvent])); + if (res.type !== 'events') throw new Error('expected events'); + const [event] = await collect(res.events); + if (event.type !== 'response.output_item.done') throw new Error('expected output_item.done'); + const item = event.item as ResponsesOutputCustomToolCall; + assertEquals(item.name, 'exec_custom'); + assertEquals(item.namespace, 'collab'); +}); + +test('leaves function_call names without `__` untouched', async () => { + const inputEvent: ResponsesStreamEvent = { + type: 'response.output_item.done', + output_index: 0, + item: functionCall('plain_tool'), + }; + const res = await withUnprefixNamespaceToolCalls(invocation(), stubCtx, runStream([inputEvent])); + if (res.type !== 'events') throw new Error('expected events'); + const [event] = await collect(res.events); + if (event.type !== 'response.output_item.done') throw new Error('expected output_item.done'); + assertEquals((event.item as ResponsesOutputFunctionCall).name, 'plain_tool'); +}); + +test('rewrites function_call inside response.completed envelope', async () => { + const inputEvent: ResponsesStreamEvent = { + type: 'response.completed', + response: { + id: 'resp_1', + object: 'response', + model: 'gpt-test', + status: 'completed', + output: [ + functionCall('collab__spawn_agent'), + customToolCall('sub__inner'), + ], + output_text: '', + error: null, + incomplete_details: null, + }, + }; + const res = await withUnprefixNamespaceToolCalls(invocation(), stubCtx, runStream([inputEvent])); + if (res.type !== 'events') throw new Error('expected events'); + const [event] = await collect(res.events); + if (event.type !== 'response.completed') throw new Error('expected response.completed'); + const [fnItem, customItem] = event.response.output; + assertEquals((fnItem as ResponsesOutputFunctionCall).name, 'spawn_agent'); + assertEquals((customItem as ResponsesOutputCustomToolCall).name, 'inner'); + assertEquals((customItem as ResponsesOutputCustomToolCall).namespace, 'sub'); +}); + +test('flag off: passes events through untouched even when names carry `__`', async () => { + const inputEvent: ResponsesStreamEvent = { + type: 'response.output_item.done', + output_index: 0, + item: functionCall('collab__spawn_agent'), + }; + const res = await withUnprefixNamespaceToolCalls(invocation(new Set()), stubCtx, runStream([inputEvent])); + if (res.type !== 'events') throw new Error('expected events'); + const [event] = await collect(res.events); + if (event.type !== 'response.output_item.done') throw new Error('expected output_item.done'); + assertEquals((event.item as ResponsesOutputFunctionCall).name, 'collab__spawn_agent'); +}); From 395d59553a75cfae2ab34af13561435c84704dfe Mon Sep 17 00:00:00 2001 From: yyyr Date: Sun, 12 Jul 2026 11:38:41 +0800 Subject: [PATCH 6/6] test(responses): add fixture-based tool_search family e2e integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives responsesAttempt.generate against a mocked chat-completions candidate with the full tool_search family payload (additional_tools + namespace + tool_search + programmatic_tool_calling + defer_loading + allowed_callers). Verifies three properties end-to-end: 1. Flag on — outbound Chat Completions body captured by the mock is fully desugared: no additional_tools item in input; tools[] flat with sub-tool names prefixed `__`; tool_search and programmatic_tool_calling entries dropped; defer_loading and allowed_callers fields stripped. 2. Flag on — mocked upstream returns a Chat Completions tool_call named `collaboration__spawn_agent`; the client-facing Responses event stream carries a function_call output item with the bare name `spawn_agent` (proves withUnprefixNamespaceToolCalls ran on the translated stream). 3. Flag off — translator throws `Invalid input item type 'additional_tools'` (rendered as 400 one layer up in serve.ts). Proves both that Layer 1 store passthrough is intact (no 502 at ingress) and that the fold interceptor is what removes additional_tools from input on flag=on. The test lives at responsesAttempt.generate — one layer below serve's error rendering — so the flag-off case asserts on the raw throw. This matches the semantic being tested (fold vs no fold) without adding a harness for serve's registry mock. Verified live against a Custom upstream fronting menci's Floway → real OpenAI gpt-4o via /chat/completions before writing this fixture; the fixture captures the same wire contract without needing a live upstream. --- .../responses/tool-search-family-e2e_test.ts | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 packages/gateway/src/data-plane/chat/responses/tool-search-family-e2e_test.ts diff --git a/packages/gateway/src/data-plane/chat/responses/tool-search-family-e2e_test.ts b/packages/gateway/src/data-plane/chat/responses/tool-search-family-e2e_test.ts new file mode 100644 index 000000000..aa9163fdc --- /dev/null +++ b/packages/gateway/src/data-plane/chat/responses/tool-search-family-e2e_test.ts @@ -0,0 +1,219 @@ +// End-to-end integration test for the tool_search family fold + unprefix flow. +// +// Drives `responsesAttempt.generate` with a payload carrying the full family +// (`additional_tools` input item with a `namespace` container of function +// sub-tools, plus top-level `tool_search` / `programmatic_tool_calling` +// hosted entries and `defer_loading` / `allowed_callers` fields) against a +// mocked chat-completions candidate. Verifies: +// +// 1. flag on — outbound body captured by the mock has: +// - no `additional_tools` item in input +// - flat tools[] with sub-tool names prefixed `__` +// - no `tool_search` / `programmatic_tool_calling` entries +// - no `defer_loading` / `allowed_callers` fields on remaining tools +// 2. flag on — upstream returns a Chat Completions tool_call named +// `collaboration__spawn_agent`; the client-facing Responses event +// stream carries a `function_call` output item with the bare name +// `spawn_agent` (proves `withUnprefixNamespaceToolCalls` ran on the +// translated response stream). +// 3. flag off — translator throws `Invalid input item type 'additional_tools'`, +// surfacing as a 400 `api-error` result (proves both Layer 1 +// store-passthrough is intact — no 502 at ingress — and that the fold +// interceptor is what removes `additional_tools` from input when the +// flag is on). + +import { test, vi } from 'vitest'; + +import { responsesAttempt } from './attempt.ts'; +import { createResponsesHttpStore } from './items/store.ts'; +import { initRepo } from '../../../repo/index.ts'; +import { InMemoryRepo } from '../../../repo/memory.ts'; +import { mockChatGatewayCtx } from '../../../test-helpers/gateway-ctx.ts'; +import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; +import { doneFrame, eventFrame, type ModelEndpoints, type ProtocolFrame } from '@floway-dev/protocols/common'; +import type { ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import { type ModelCandidate, directFetcher, type FlagId, type ProviderStreamResult, type UpstreamCallOptions } from '@floway-dev/provider'; +import { assert, assertEquals, stubProvider, stubInternalModel, stubProviderModel } from '@floway-dev/test-utils'; +import type { CanonicalResponsesPayload } from '@floway-dev/translate/via-responses/responses-items'; + +const API_KEY_ID = 'key_tool_search_e2e'; + +const chatCompletionsCandidate = ( + callChatCompletions: (model: unknown, body: unknown, signal: AbortSignal | undefined, opts: UpstreamCallOptions) => Promise>, + enabledFlags: ReadonlySet, +): ModelCandidate => { + const endpoints: ModelEndpoints = { chatCompletions: {} }; + const upstream = 'up_test'; + const providerModel = stubProviderModel({ id: 'test-model', kind: 'chat', endpoints, enabledFlags }); + return { + provider: { + upstream, + kind: 'custom', + name: upstream, + disabledPublicModelIds: [], + modelPrefix: null, + instance: stubProvider({ callChatCompletions }), + supportsResponsesItemReference: false, + }, + model: stubInternalModel({ id: 'test-model', kind: 'chat', endpoints, providerModels: { [upstream]: providerModel } }, upstream), + fetcher: directFetcher, + }; +}; + +const chatCompletionsToolCallStream = (functionName: string): AsyncGenerator> => { + const chunk = (delta: Partial, finish_reason: ChatCompletionsStreamEvent['choices'][number]['finish_reason'] = null): ChatCompletionsStreamEvent => ({ + id: 'chatcmpl_test', + object: 'chat.completion.chunk', + created: 0, + model: 'test-model', + choices: [{ index: 0, delta, finish_reason }], + }); + const events: ChatCompletionsStreamEvent[] = [ + chunk({ role: 'assistant', content: null }), + chunk({ tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: functionName, arguments: '' } }] }), + chunk({ tool_calls: [{ index: 0, function: { arguments: '{"name":"researcher","prompt":"hi"}' } }] }), + chunk({}, 'tool_calls'), + ]; + return (async function* () { + for (const event of events) yield eventFrame(event); + yield doneFrame(); + })(); +}; + +const installRepo = () => { + const repo = new InMemoryRepo(); + initRepo(repo); + return repo; +}; + +const makePayload = (): CanonicalResponsesPayload => ({ + model: 'test-model', + input: [ + { type: 'message', role: 'user', content: 'delegate this' }, + { + type: 'additional_tools', + role: 'developer', + tools: [ + { type: 'function', name: 'wait', parameters: { type: 'object' }, strict: false }, + { + type: 'namespace', + name: 'collaboration', + tools: [ + { type: 'function', name: 'spawn_agent', parameters: { type: 'object' }, strict: false, defer_loading: true }, + { type: 'function', name: 'send_message', parameters: { type: 'object' }, strict: false }, + ], + }, + ], + }, + ] as CanonicalResponsesPayload['input'], + tools: [ + { type: 'function', name: 'keep_me', parameters: { type: 'object' }, strict: false, allowed_callers: ['programmatic'] }, + { type: 'tool_search' }, + { type: 'programmatic_tool_calling' }, + ], +}); + +const collectEvents = async (events: AsyncIterable>): Promise => { + const out: ResponsesStreamEvent[] = []; + for await (const frame of events) if (frame.type === 'event') out.push(frame.event); + return out; +}; + +test('flag on — outbound Chat Completions body is fully desugared', async () => { + installRepo(); + const outboundBody = vi.fn(); + const callChatCompletions = vi.fn(async (_model: unknown, body: unknown): Promise> => { + outboundBody(body); + return { ok: true, events: chatCompletionsToolCallStream('collaboration__spawn_agent'), modelKey: 'test-model-key', headers: new Headers() }; + }); + const candidate = chatCompletionsCandidate(callChatCompletions, new Set(['flatten-tool-search-family'])); + const store = createResponsesHttpStore(API_KEY_ID, true); + const ctx = mockChatGatewayCtx({ apiKeyId: API_KEY_ID, wantsStream: true, store }); + vi.spyOn(store, 'commitSnapshot').mockResolvedValue(); + + const result = await responsesAttempt.generate({ payload: makePayload(), ctx, candidate, headers: new Headers() }); + assertEquals(result.type, 'events'); + if (result.type !== 'events') throw new Error('unreachable'); + await collectEvents(result.events); + + assertEquals(outboundBody.mock.calls.length, 1); + const body = outboundBody.mock.calls[0][0] as { messages: unknown[]; tools: { type: string; function: { name: string; description?: string } }[] }; + + // No additional_tools item survives — the interceptor stripped it before + // the translator ran. If it were still in input, the translator would have + // thrown before reaching this mock. + const messages = body.messages as { role: string; content: unknown }[]; + assert(!messages.some(m => (m as { type?: string }).type === 'additional_tools'), 'additional_tools must not appear in translated messages'); + + // Chat Completions wraps custom + function tools as function tools. + const toolNames = body.tools.map(t => t.function.name); + assert(toolNames.includes('wait'), 'top-level `wait` function survives'); + assert(toolNames.includes('keep_me'), 'top-level `keep_me` function survives'); + assert(toolNames.includes('collaboration__spawn_agent'), 'namespace sub-tool prefixed as collaboration__spawn_agent'); + assert(toolNames.includes('collaboration__send_message'), 'namespace sub-tool prefixed as collaboration__send_message'); + // Dropped by flattenToolSearchFamilyTools. + assert(!toolNames.includes('tool_search'), '`tool_search` hosted entry dropped'); + assert(!toolNames.includes('programmatic_tool_calling'), '`programmatic_tool_calling` hosted entry dropped'); + // Fields stripped. + const keepMeTool = body.tools.find(t => t.function.name === 'keep_me') as { function: { allowed_callers?: unknown } }; + assertEquals((keepMeTool.function as { allowed_callers?: unknown }).allowed_callers, undefined); + const collabSpawn = body.tools.find(t => t.function.name === 'collaboration__spawn_agent') as { function: { defer_loading?: unknown } }; + assertEquals((collabSpawn.function as { defer_loading?: unknown }).defer_loading, undefined); +}); + +test('flag on — response tool_call name is unprefixed at client egress', async () => { + installRepo(); + const callChatCompletions = vi.fn(async (): Promise> => ({ + ok: true, events: chatCompletionsToolCallStream('collaboration__spawn_agent'), modelKey: 'test-model-key', headers: new Headers(), + })); + const candidate = chatCompletionsCandidate(callChatCompletions, new Set(['flatten-tool-search-family'])); + const store = createResponsesHttpStore(API_KEY_ID, true); + const ctx = mockChatGatewayCtx({ apiKeyId: API_KEY_ID, wantsStream: true, store }); + vi.spyOn(store, 'commitSnapshot').mockResolvedValue(); + + const result = await responsesAttempt.generate({ payload: makePayload(), ctx, candidate, headers: new Headers() }); + assertEquals(result.type, 'events'); + if (result.type !== 'events') throw new Error('unreachable'); + const events = await collectEvents(result.events); + + // Locate the function_call output item — either on `output_item.done` or + // inside the terminal envelope. + const doneFrame = events.find(e => e.type === 'response.output_item.done' && (e as { item: { type: string } }).item.type === 'function_call') as { item: { type: string; name: string } } | undefined; + const completedFrame = events.find(e => e.type === 'response.completed') as { response: { output: { type: string; name?: string }[] } } | undefined; + const namesFromDone = doneFrame ? [doneFrame.item.name] : []; + const namesFromCompleted = completedFrame?.response.output.filter(i => i.type === 'function_call').map(i => i.name!) ?? []; + const allNames = [...namesFromDone, ...namesFromCompleted]; + + assert(allNames.length > 0, 'expected at least one function_call output item'); + for (const name of allNames) { + assertEquals(name, 'spawn_agent', `expected bare name, got ${name}`); + assert(!name.includes('__'), `namespace prefix must be stripped, got ${name}`); + } +}); + +test('flag off — translator throws TranslatorInputError on additional_tools (rendered as 400 one layer up)', async () => { + installRepo(); + const callChatCompletions = vi.fn(async (): Promise> => { + throw new Error('callChatCompletions should not be invoked when translator rejects input'); + }); + const candidate = chatCompletionsCandidate(callChatCompletions, new Set()); + const store = createResponsesHttpStore(API_KEY_ID, true); + const ctx = mockChatGatewayCtx({ apiKeyId: API_KEY_ID, wantsStream: true, store }); + + // At the attempt.ts layer the error propagates raw; serve.ts's caller + // catches and renders it via translatorInputErrorResult. We assert on the + // raw throw here — that is the observable signal that the fold + // interceptor DID NOT run (otherwise `additional_tools` would have been + // stripped from input before the translator saw it). + let thrown: unknown = null; + try { + await responsesAttempt.generate({ payload: makePayload(), ctx, candidate, headers: new Headers() }); + } catch (error) { + thrown = error; + } + assert(thrown !== null, 'expected translator to throw when flag is off'); + const err = thrown as { name?: string; message?: string }; + assertEquals(err.name, 'TranslatorInputError'); + assert(err.message?.includes('additional_tools') ?? false, `expected additional_tools in error, got: ${err.message}`); + assertEquals(callChatCompletions.mock.calls.length, 0); +});