Skip to content

feat: passthrough OpenAI tool_search family; flatten-tool-search-family flag#183

Open
yyyr-p wants to merge 6 commits into
Menci:mainfrom
yyyr-p:feat/tool-search-family
Open

feat: passthrough OpenAI tool_search family; flatten-tool-search-family flag#183
yyyr-p wants to merge 6 commits into
Menci:mainfrom
yyyr-p:feat/tool-search-family

Conversation

@yyyr-p

@yyyr-p yyyr-p commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Floway 502'd on any /v1/responses request whose input[] carried a type: 'additional_tools' item — the gpt-5.4+ OpenAI Responses API tool_search feature family (additional_tools input items, type: 'namespace' tool containers, type: 'tool_search' / programmatic_tool_calling hosted tools, defer_loading / allowed_callers function-tool fields, program / program_output output items) was entirely unknown to the protocol / store / translate layers. stageInputItem threw a TypeError for the unknown item type; further latent gaps existed for the family's other members that would surface as clients start using them.

The change lands in two layers:

Layer 1 — unconditional passthrough (no flag): make Floway an opaque gateway for the whole family, per the repo's Upstream Models And Field Values Are Opaque design principle. Protocol union collects the new input/output item variants, hosted-tool discriminator, function/custom tool fields, and function_call.caller. Store registers program: 'pg' / program_output: 'pgo' prefixes so PTC output items survive snapshot storage, and skip-persists additional_tools (turn-scoped developer directive re-declared verbatim by the client on every request — mirrors compaction_trigger).

Layer 2 — opt-in flatten to legacy shape (flatten-tool-search-family flag): for upstreams that do not implement the family (translated Chat Completions / Messages targets; Responses endpoints stuck below gpt-5.4), one interceptor rewrites the request in a single pass — move additional_tools[].tools into top-level payload.tools[]; unpack namespace containers (sub-tool names prefixed ` __ `); drop tool_search / programmatic_tool_calling entries; strip defer_loading / allowed_callers. A response-side interceptor scans the downstream event stream and reverses the name-prefixing on function_call (bare name) and custom_tool_call (bare name + namespace field) output items so clients match against their originally-declared tool registry.

Provider defaults: false for OpenAI-native (codex / copilot / azure / custom); true for translated targets (ollama / claude-code) that need the desugar to make the request survive translation.

Also fixes a pre-existing silent-drop bug in `responses-via-chat-completions` and `responses-via-messages` translators — their `translateResponsesTools` narrowed to `function` / `custom` and let `namespace` container tools fall through, silently losing the nested sub-tools. Both now apply the same shared `flattenToolSearchFamilyTools` helper unconditionally so sub-tools survive translation with the same `__` prefix that the response-side interceptor reverses.

Structured as 6 commits following the layering:

  1. fix(responses): accept OpenAI tool_search family artifacts on passthrough
  2. feat(protocols): tool_search family desugar helpers
  3. fix(translate): unpack namespace sub-tools instead of silently dropping
  4. feat(flags): flatten-tool-search-family for legacy upstreams
  5. feat(responses): flatten-tool-search-family request/response interceptors
  6. test(responses): add fixture-based tool_search family e2e integration test

Refs:

Test plan

  • Unit: `unpackNamespaceTools` / `unprefixNamespaceToolCall` / `flattenToolSearchFamilyTools` helpers — edge cases (empty namespace, malformed `tools`, multi-`__` delimiter, all six flatten targets present).
  • Unit: `withFlattenToolSearchFamily` interceptor — flag on runs the six-op desugar; flag off is a no-op; no-op detection preserves reference identity.
  • Unit: `withUnprefixNamespaceToolCalls` interceptor — `function_call` bare-name reverse; `custom_tool_call` bare-name + `namespace` field populate; `response.completed` envelope rewrite; passthrough of non-`__` names; flag off is a no-op.
  • Regression: `additional_tools` and `compaction_trigger` throwing in `prefixForItemType` (skip-persist invariant pins).
  • Integration: fixture-based e2e test drives `responsesAttempt.generate` against a mocked chat-completions candidate with the full tool_search family payload — asserts (a) outbound Chat Completions body captured is fully desugared; (b) upstream tool_call arrives at the client with a bare name; (c) flag off causes the translator to reject `additional_tools` (proving the fold interceptor is what removes it on flag on).
  • Workspace: `pnpm -r run typecheck`, `pnpm run lint`, `pnpm run test` (4016 tests) all green at HEAD.

yyyr-p added 6 commits July 12, 2026 10:31
…ough

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
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 `<namespace>__`. 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
`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 `<namespace>__`, 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.
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.
…tors

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 `<namespace>__`),
    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
    `<namespace>__` 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.
… test

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 `<namespace>__`; 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.
@yyyr-p

yyyr-p commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Known limitations of the flatten-tool-search-family interceptors

The flatten-tool-search-family flag makes the tool_search family survive routing to a non-native-Responses endpoint (translated Chat Completions / Messages targets, or legacy pre-gpt-5.4 OpenAI-compat proxies), but it does so by desugaring to a legacy shape — some family semantics have no equivalent on the target side and are unavoidably lost. Two are worth flagging as follow-up work rather than accepted tradeoffs:

1. programmatic_tool_calling — currently only a workaround

The interceptor drops the type: 'programmatic_tool_calling' entry from payload.tools[] and strips the allowed_callers: ['programmatic'] restriction from function tools so they become model-callable directly. The model then no longer has access to the JS runtime that would let it batch/orchestrate multiple tool calls in one turn — every tool call becomes a separate model-driven round trip.

This is a capability regression, not a lossless translation:

  • The model can still call each individual tool, but only one at a time via normal function-call turns.
  • Any tool whose only intended caller was the PTC program (allowed_callers: ['programmatic']) becomes unrestricted; a client that declared such a tool assuming PTC-only access loses the constraint entirely.
  • Response items program / program_output / function_call.caller.type: 'program' are Layer-1-accepted on the ingress side (so passthrough on native paths works) but the model doesn't emit them on the desugared path because PTC isn't in its toolbox anymore.

A proper fix would require Floway to synthesize a PTC executor: expose a shim tool the model can call with JS code, run that code in a sandboxed V8 isolate (or equivalent), route its inner tool invocations through the same interceptor chain, and aggregate the results back into a program_output-shaped envelope. Non-trivial infrastructure. Should be tracked for a follow-up; the current drop is documented in the flag description as "loses PTC orchestration" so operators know what they're opting into.

2. additional_tools — moving to top-level tools[] breaks prompt caching

Native semantics: additional_tools is a turn-scoped input item that sits inline in the conversation at the position it was declared. Upstreams that support it can cache the request prefix up to that point unchanged across turns; only content at or after the item participates in re-hashing.

Desugared semantics (flag on): the interceptor moves the item's tools[] into top-level payload.tools[] and drops the item from input[]. From the upstream's cache-key perspective, the tools[] field is now serialized at the top of the request and any change to the client's additional_tools set (adding a tool, changing a description, renaming a sub-tool) rewrites tools[] — which invalidates the entire prefix cache for every subsequent turn.

Impact: repeat conversations that use additional_tools may see substantially degraded prompt-cache hit rates on the desugared path — potentially the difference between cache-hit pricing and full-input pricing on every turn after the first tool-list change.

There is no workaround inside this PR: target protocols (Chat Completions, Messages) have no equivalent of a mid-conversation tool-declaration item, so preserving cache-friendly positioning is structurally impossible for the family desugar. Operators aware of this can mitigate by keeping additional_tools sets stable across turns (or accepting the cost).

Minor additional losses

For completeness, the flag also drops:

  • defer_loading: true — all sub-tool schemas are declared eagerly; the token savings that tool_search-driven lazy loading would deliver are gone.
  • custom_tool_call.namespace round-trip fidelity for function-typed sub-toolsfunction_call has no namespace field in the Responses schema, so the response side can only strip the <namespace>__ prefix back to bare name; namespace context is lost. (custom_tool_call sub-tools do keep the namespace, because that item type has the field.)

These two are unlikely to warrant a follow-up on their own but are surfaced here so the tradeoff surface of the flag is fully documented.

Where these are documented in the codebase

  • Flag description in packages/provider/src/flags.ts ends with "Loses defer_loading eager-vs-lazy semantics, PTC orchestration, and custom_tool_call.namespace round-trip fidelity." — surfaces losses to operators.
  • Interceptor file headers point at the same tradeoffs.

The above should be tracked as follow-up issues; happy to open them separately if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant