Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface AdapterRequest {
method: string;
headers: Record<string, string>;
body: string;
/** Custom-tool names actually lowered to upstream function calls while building this request. */
convertedRoutedCustomToolNames?: ReadonlySet<string>;
/** Releases observation of a serialized request body after its final fetch attempt settles. */
releaseBodyObservation?: () => void;
/** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */
Expand Down
6 changes: 5 additions & 1 deletion src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1361,6 +1361,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
}

const forward = provider.authMode === "forward";
let convertedRoutedCustomToolNames: Set<string> | undefined;
const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true;
let outBody = stripPreviousResponseId(
parsed._rawBody,
Expand Down Expand Up @@ -1408,7 +1409,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
outBody = promoteClientLoadedTools(outBody);
}
if (provider.authMode !== "forward") {
outBody = rewriteRoutedCustomToolsForUpstream(outBody).body;
const rewritten = rewriteRoutedCustomToolsForUpstream(outBody);
outBody = rewritten.body;
convertedRoutedCustomToolNames = rewritten.names;
}
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true })))))));
const body = JSON.stringify(stripDisabledReasoningSummaries(
Expand All @@ -1426,6 +1429,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
headers,
body,
releaseBodyObservation,
...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
};
},

Expand Down
11 changes: 7 additions & 4 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ import {
payloadRewriteAsBlockRewrite,
relaySseWithBlockRewrite,
} from "../sse-payload-rewrite";
import { collectRoutedCustomToolNames, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair";
import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
import { responsesJsonToSseStream } from "../responses-json-events";
Expand Down Expand Up @@ -2130,9 +2130,7 @@ async function handleResponsesInner(
const imageGenCallAliases = route.provider.authMode === "forward"
? new Map<string, { namespace: string; name: string }>()
: imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget);
const routedCustomToolNames = route.provider.authMode === "forward"
? new Set<string>()
: collectRoutedCustomToolNames(parsed._rawBody);
const routedCustomToolNames = new Set<string>();
// Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
// previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
// REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
Expand Down Expand Up @@ -2161,6 +2159,11 @@ async function handleResponsesInner(
releaseCodexAuthContextProbeLease(authCtx);
throw error;
}
if (route.provider.authMode !== "forward") {
for (const name of request.convertedRoutedCustomToolNames ?? []) {
if (toolBridgeMaps.freeformToolNames.has(name)) routedCustomToolNames.add(name);
}
}
recordAdapterReasoning(logCtx, request);
const actualHostKey = upstreamHostHealthKey(
route.providerName,
Expand Down
1 change: 1 addition & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Responses-compatible streaming output.
- 기존 구현 및 제약 조건: The request catalog already controlled custom-tool restoration and the non-OpenAI prompt nudge, but an undeclared upstream name still fell through as an ordinary `function_call`; Codex then reduced the mismatch to a bare `aborted` result.
- 검토한 주요 대안: Rely only on prompt guidance; automatically translate undeclared `apply_patch` into Code Mode; validate returned names against the request-visible catalog at the final bridge.
- 선택한 방식: Retain the allowed wire-name set with the existing bridge maps and fail the turn with an explicit compatibility error before emitting any undeclared tool item.
- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` and tools replaced by hosted-provider policy stay in their upstream function-call form.
- 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation.
- 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior.

Expand Down
242 changes: 242 additions & 0 deletions tests/responses-custom-tool-repair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,248 @@ describe("routed Responses custom-tool compatibility", () => {
}
});

test("handleResponses does not restore routed custom calls excluded by request policy", async () => {
const savedFetch = globalThis.fetch;
const upstreamItem = {
type: "function_call",
id: "fc_exec",
call_id: "call_exec",
name: "exec",
arguments: "{\"input\":\"ignored policy\"}",
status: "completed",
};
const config = {
port: 0,
defaultProvider: "fixture",
providers: {
fixture: {
adapter: "openai-responses",
baseUrl: "https://fixture.test/v1",
authMode: "key",
apiKey: "fixture-key",
},
},
} as OcxConfig;
const execTool = {
type: "custom",
name: "exec",
description: "Run JavaScript",
format: { type: "grammar", syntax: "lark" },
};
const ordinaryTool = {
type: "function",
name: "ordinary",
description: "Ordinary function",
parameters: { type: "object" },
};
const cases: Array<{
name: string;
stream: boolean;
tools: Array<Record<string, unknown>>;
toolChoice?: unknown;
metadata?: unknown;
}> = [
{
name: "streaming none",
stream: true,
tools: [execTool],
toolChoice: "none",
},
{
name: "streaming allowlist",
stream: true,
tools: [execTool, ordinaryTool],
toolChoice: {
type: "allowed_tools",
mode: "required",
tools: [{ type: "function", name: "ordinary" }],
},
},
{
name: "named ordinary function",
stream: false,
tools: [execTool, ordinaryTool],
toolChoice: { type: "function", name: "ordinary" },
},
{
name: "custom-looking metadata without a declared tool",
stream: false,
tools: [ordinaryTool],
metadata: { nested: { type: "custom", name: "exec" } },
},
];

globalThis.fetch = (async (_input, init) => {
const outboundBody = JSON.parse(String(init?.body)) as { stream?: boolean };
if (outboundBody.stream === true) {
const upstream = [
frame("response.output_item.added", {
output_index: 0,
item: { ...upstreamItem, arguments: "", status: "in_progress" },
}),
frame("response.function_call_arguments.done", {
output_index: 0,
item_id: "fc_exec",
arguments: upstreamItem.arguments,
}),
frame("response.output_item.done", { output_index: 0, item: upstreamItem }),
frame("response.completed", {
response: { id: "resp_policy", status: "completed", output: [upstreamItem] },
}),
"data: [DONE]",
].join("\n\n") + "\n\n";
return new Response(upstream, { headers: { "content-type": "text/event-stream" } });
}
return new Response(JSON.stringify({
id: "resp_policy",
status: "completed",
output: [upstreamItem],
}), { headers: { "content-type": "application/json" } });
}) as typeof fetch;

try {
for (const policyCase of cases) {
const response = await handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "fixture/deepseek-v4-flash",
stream: policyCase.stream,
input: [{ role: "user", content: [{ type: "input_text", text: policyCase.name }] }],
tools: policyCase.tools,
...(policyCase.toolChoice !== undefined ? { tool_choice: policyCase.toolChoice } : {}),
...(policyCase.metadata !== undefined ? { metadata: policyCase.metadata } : {}),
}),
}), config, { model: "", provider: "" });

if (policyCase.stream) {
const clientSse = await response.text();
expect(clientSse).toContain('"type":"function_call"');
expect(clientSse).toContain('"id":"fc_exec"');
expect(clientSse).toContain("response.function_call_arguments.done");
expect(clientSse).not.toContain("custom_tool_call");
expect(clientSse).not.toContain("ctc_exec");
} else {
const body = await response.json() as { output: Array<Record<string, unknown>> };
expect(body.output[0]).toEqual(upstreamItem);
}
}
} finally {
globalThis.fetch = savedFetch;
}
});

test("handleResponses preserves native apply_patch calls that were never converted", async () => {
const savedFetch = globalThis.fetch;
const upstreamItem = {
type: "function_call",
id: "fc_patch",
call_id: "call_patch",
name: "apply_patch",
arguments: "{\"patch\":\"*** Begin Patch\"}",
status: "completed",
};
globalThis.fetch = (async () => new Response(JSON.stringify({
id: "resp_patch",
status: "completed",
output: [upstreamItem],
}), { headers: { "content-type": "application/json" } })) as typeof fetch;
const config = {
port: 0,
defaultProvider: "fixture",
providers: {
fixture: {
adapter: "openai-responses",
baseUrl: "https://fixture.test/v1",
authMode: "key",
apiKey: "fixture-key",
},
},
} as OcxConfig;

try {
const response = await handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "fixture/deepseek-v4-flash",
stream: false,
input: [{ role: "user", content: [{ type: "input_text", text: "patch" }] }],
tools: [{
type: "custom",
name: "apply_patch",
description: "Apply a patch",
format: { type: "grammar", syntax: "lark" },
}],
}),
}), config, { model: "", provider: "" });
const body = await response.json() as { output: Array<Record<string, unknown>> };

expect(body.output[0]).toEqual(upstreamItem);
} finally {
globalThis.fetch = savedFetch;
}
});

test("handleResponses does not restore a custom image tool replaced by hosted preference", async () => {
const savedFetch = globalThis.fetch;
let outboundBody: Record<string, unknown> | undefined;
const upstreamItem = {
type: "function_call",
id: "fc_image",
call_id: "call_image",
name: "image_gen.generate",
arguments: "{}",
status: "completed",
};
globalThis.fetch = (async (_input, init) => {
outboundBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
return new Response(JSON.stringify({
id: "resp_image",
status: "completed",
output: [upstreamItem],
}), { headers: { "content-type": "application/json" } });
}) as typeof fetch;
const config = {
port: 0,
defaultProvider: "fixture",
providers: {
fixture: {
adapter: "openai-responses",
baseUrl: "https://fixture.test/v1",
authMode: "key",
apiKey: "fixture-key",
modelPreferHostedTools: { "deepseek-v4-flash": ["image_generation"] },
},
},
} as OcxConfig;

try {
const response = await handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "fixture/deepseek-v4-flash",
stream: false,
input: [{ role: "user", content: [{ type: "input_text", text: "draw" }] }],
tools: [{
type: "custom",
name: "image_gen.generate",
description: "Generate an image",
format: { type: "grammar", syntax: "lark" },
}],
}),
}), config, { model: "", provider: "" });
const body = await response.json() as { output: Array<Record<string, unknown>> };
const outboundTools = outboundBody?.tools as Array<Record<string, unknown>> | undefined;

expect(outboundTools).toEqual([{ type: "image_generation" }]);
expect(body.output[0]).toEqual(upstreamItem);
} finally {
globalThis.fetch = savedFetch;
}
});

test("handleResponses leaves custom tools native for forward-auth passthrough", async () => {
const savedFetch = globalThis.fetch;
let outboundBody: Record<string, unknown> | undefined;
Expand Down
Loading