Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 `<namespace>__` 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();
};
Original file line number Diff line number Diff line change
@@ -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<FlagId> = 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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
Expand All @@ -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([
Expand All @@ -60,10 +75,12 @@ export const responsesInterceptors: readonly ResponsesInterceptor[] = [
]),
withCyberPolicyRetried,
withReasoningDisabledOnForcedToolChoice,
withFlattenToolSearchFamily,
withDemoteDeveloperToSystem,
withInterleavedSystemDemotedToUser,
withPromptCacheKeyStripped,
withVendorDeepseekResponsesNormalize,
withVendorQwenResponsesNormalize,
withUnprefixNamespaceToolCalls,
withResponsesOutputItemsCanonicalized,
];
Original file line number Diff line number Diff line change
@@ -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 `<namespace>__<name>`; 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<ProtocolFrame<ResponsesStreamEvent>>,
): AsyncGenerator<ProtocolFrame<ResponsesStreamEvent>> {
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<ProtocolFrame<ResponsesStreamEvent>> = await run();
if (result.type !== 'events') return result;

return eventResult(unprefixNamespaceToolCalls(result.events), result.modelIdentity, {
performance: result.performance,
finalMetadata: result.finalMetadata,
headers: result.headers,
});
};
Loading