Restore Responses API custom tool calls (#7905)

* fix: restore Responses custom tool calls

* fix: preserve nested Responses custom tool calls

* fix: reset superseded tool call state

* fix: preserve tool precedence and buffered Responses tool arguments

* test: verify top-level tool descriptions take precedence

* fix: preserve custom Responses tool streaming semantics

* test: cover declared custom tool streaming round trips

* test: cover Responses custom tool metadata collection

* test: cover active Responses custom tool stream

* test: isolate Responses active stream regression

* fix: complete Responses custom tool round trips
This commit is contained in:
Jan Leon
2026-07-21 03:22:24 +02:00
committed by GitHub
parent 10823bcf83
commit 6b59e814da
14 changed files with 735 additions and 83 deletions

View File

@@ -74,6 +74,7 @@ import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts";
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
import { collectCustomToolNamesForSourceFormat } from "../translator/request/openai-responses/additionalTools.ts";
import { sanitizeKiroTools } from "../utils/kiroSanitizer.ts";
import { splitMisplacedToolResults } from "../translator/helpers/claudeHelper.ts";
import {
@@ -614,6 +615,13 @@ export async function handleChatCore({
copilotCompatibleReasoning,
clientResponseFormat,
} = resolveChatCoreRequestFormat({ clientRawRequest, body, provider, userAgent });
const responsesInputItems = Array.isArray(body?.input) ? body.input : [];
const customToolNames = collectCustomToolNamesForSourceFormat(
sourceFormat,
FORMATS.OPENAI_RESPONSES,
body?.tools,
responsesInputItems
);
// Check for bypass patterns (warmup, skip) - return fake response
const bypassResponse = handleBypassRequest(body, model, userAgent);
@@ -4618,7 +4626,8 @@ export async function handleChatCore({
userAgent: streamUserAgent,
thinkingMarkerHeader,
clientResponseFormat,
})
}),
customToolNames
);
} else {
log?.debug?.("STREAM", `Standard passthrough mode`);

View File

@@ -6,6 +6,7 @@ import { CORS_HEADERS } from "../utils/cors.ts";
import { handleChatCore } from "./chatCore.ts";
import { convertResponsesApiFormat } from "../translator/helpers/responsesApiHelper.ts";
import { collectResponsesCustomToolNames } from "../translator/request/openai-responses/additionalTools.ts";
import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.ts";
import { createSseHeartbeatTransform, HEARTBEAT_SHAPES } from "../utils/sseHeartbeat.ts";
import { SSE_HEARTBEAT_INTERVAL_MS } from "../config/constants.ts";
@@ -35,6 +36,9 @@ export async function handleResponsesCore({
connectionId,
signal,
}) {
const inputItems = Array.isArray(body?.input) ? body.input : [];
const customToolNames = collectResponsesCustomToolNames(body?.tools, inputItems);
// Convert Responses API format to Chat Completions format
const convertedBody = convertResponsesApiFormat(body, credentials);
@@ -69,7 +73,7 @@ export async function handleResponsesCore({
}
// Transform SSE stream to Responses API format (no logging in worker)
const transformStream = createResponsesApiTransformStream(null);
const transformStream = createResponsesApiTransformStream(null, undefined, { customToolNames });
const transformedBody = response.body.pipeThrough(transformStream).pipeThrough(
createSseHeartbeatTransform({
signal,

View File

@@ -75,9 +75,16 @@ export function createResponsesLogger(model, logsDir = null) {
/**
* Create TransformStream that converts Chat Completions SSE to Responses API SSE
* @param {Object} logger - Optional logger instance
* @param {number} keepaliveIntervalMs - Keepalive interval in milliseconds
* @param {{ customToolNames?: Iterable<string> }} options - Original Responses tool metadata
* @returns {TransformStream}
*/
export function createResponsesApiTransformStream(logger = null, keepaliveIntervalMs = 3000) {
export function createResponsesApiTransformStream(
logger = null,
keepaliveIntervalMs = 3000,
options = {}
) {
const customToolNames = new Set(options.customToolNames || []);
const state = {
seq: 0,
responseId: `resp_${Date.now()}`,
@@ -97,6 +104,8 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv
funcArgsBuf: {},
funcNames: {},
funcCallIds: {},
funcItemAdded: {},
funcItemTypes: {},
funcArgsDone: {},
funcItemDone: {},
completedOutputItems: [] as Array<{
@@ -270,46 +279,106 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv
}
};
const emitToolCallAdded = (controller, idx) => {
if (state.funcItemAdded[idx] || !state.funcCallIds[idx]) return false;
const customTool = customToolNames.has(state.funcNames[idx] || "");
const itemType = customTool ? "custom_tool_call" : "function_call";
state.funcItemTypes[idx] = itemType;
state.funcItemAdded[idx] = true;
emit(controller, "response.output_item.added", {
type: "response.output_item.added",
output_index: idx,
item: {
id: `fc_${state.funcCallIds[idx]}`,
type: itemType,
...(customTool ? { input: "" } : { arguments: "" }),
call_id: state.funcCallIds[idx],
name: state.funcNames[idx] || "",
...(customTool ? { status: "in_progress" } : {}),
},
});
return true;
};
const closeToolCall = (controller, idx, recordAsCompleted = true) => {
const callId = state.funcCallIds[idx];
if (callId && !state.funcItemDone[idx]) {
const normalizedIndex = normalizeOutputIndex(idx);
let args = state.funcArgsBuf[idx] || "{}";
const toolName = state.funcNames[idx] || "";
emitToolCallAdded(controller, idx);
const isCustomTool = state.funcItemTypes[idx] === "custom_tool_call";
// Fix #1674 & #1852: Final cleanup of empty string and empty array placeholders
try {
const parsed = JSON.parse(args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
let modified = false;
for (const [k, v] of Object.entries(parsed)) {
if (v === "" || (Array.isArray(v) && v.length === 0)) {
delete parsed[k];
modified = true;
// Fix #1674 & #1852: Final cleanup of empty string and empty array placeholders.
// Custom-tool input is intentionally allowed to be an empty string.
if (!isCustomTool) {
try {
const parsed = JSON.parse(args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
let modified = false;
for (const [k, v] of Object.entries(parsed)) {
if (v === "" || (Array.isArray(v) && v.length === 0)) {
delete parsed[k];
modified = true;
}
}
if (modified) {
args = JSON.stringify(parsed);
state.funcArgsBuf[idx] = args;
}
}
if (modified) {
args = JSON.stringify(parsed);
state.funcArgsBuf[idx] = args;
}
} catch (e) {
// Ignore malformed JSON
}
} catch (e) {
// Ignore malformed JSON
}
emit(controller, "response.function_call_arguments.done", {
type: "response.function_call_arguments.done",
item_id: `fc_${callId}`,
output_index: normalizedIndex,
arguments: args,
});
let funcItem;
if (isCustomTool) {
let rawInput = args;
try {
const parsed = JSON.parse(args);
if (parsed && typeof parsed.input === "string") rawInput = parsed.input;
} catch {
// A non-JSON argument is already the raw custom-tool input.
}
const funcItem = {
id: `fc_${callId}`,
type: "function_call",
arguments: args,
call_id: callId,
name: state.funcNames[idx] || "",
};
emit(controller, "response.custom_tool_call_input.delta", {
type: "response.custom_tool_call_input.delta",
item_id: `fc_${callId}`,
output_index: normalizedIndex,
delta: rawInput,
});
emit(controller, "response.custom_tool_call_input.done", {
type: "response.custom_tool_call_input.done",
item_id: `fc_${callId}`,
output_index: normalizedIndex,
input: rawInput,
});
funcItem = {
id: `fc_${callId}`,
type: "custom_tool_call",
input: rawInput,
call_id: callId,
name: toolName,
status: "completed",
};
} else {
emit(controller, "response.function_call_arguments.done", {
type: "response.function_call_arguments.done",
item_id: `fc_${callId}`,
output_index: normalizedIndex,
arguments: args,
});
funcItem = {
id: `fc_${callId}`,
type: "function_call",
arguments: args,
call_id: callId,
name: toolName,
};
}
emit(controller, "response.output_item.done", {
type: "response.output_item.done",
@@ -576,6 +645,8 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv
delete state.funcCallIds[tcIdx];
delete state.funcNames[tcIdx];
delete state.funcArgsBuf[tcIdx];
delete state.funcItemAdded[tcIdx];
delete state.funcItemTypes[tcIdx];
delete state.funcArgsDone[tcIdx];
delete state.funcItemDone[tcIdx];
}
@@ -584,18 +655,25 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv
if (!state.funcCallIds[tcIdx] && newCallId) {
state.funcCallIds[tcIdx] = newCallId;
}
emit(controller, "response.output_item.added", {
type: "response.output_item.added",
output_index: tcIdx,
item: {
id: `fc_${newCallId}`,
type: "function_call",
arguments: "",
call_id: newCallId,
name: state.funcNames[tcIdx] || "",
},
});
// The provider may send the call id before the function name. Defer the
// lifecycle item until the name is available so custom calls are not first
// announced as function calls.
if (state.funcCallIds[tcIdx] && state.funcNames[tcIdx]) {
const itemAdded = emitToolCallAdded(controller, tcIdx);
if (
itemAdded &&
state.funcItemTypes[tcIdx] !== "custom_tool_call" &&
state.funcArgsBuf[tcIdx]
) {
emit(controller, "response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${state.funcCallIds[tcIdx]}`,
output_index: tcIdx,
delta: state.funcArgsBuf[tcIdx],
});
}
}
if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = "";
@@ -622,7 +700,12 @@ export function createResponsesApiTransformStream(logger = null, keepaliveInterv
const emittedDelta = nextArgs.slice(existingArgs.length);
state.funcArgsBuf[tcIdx] = nextArgs;
if (refCallId && emittedDelta) {
if (
refCallId &&
emittedDelta &&
state.funcItemAdded[tcIdx] &&
state.funcItemTypes[tcIdx] !== "custom_tool_call"
) {
emit(controller, "response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${refCallId}`,

View File

@@ -684,6 +684,7 @@ export function initState(sourceFormat) {
funcNames: {},
funcCallIds: {},
funcArgsDone: {},
funcItemAdded: {},
funcItemDone: {},
completedOutputItems: [],
completedSent: false,

View File

@@ -403,11 +403,20 @@ export function openaiResponsesToOpenAIRequest(
function: {
name: toString(sub.name),
description: toString(sub.description),
parameters: sub.parameters ??
sub.input_schema ?? {
type: "object",
properties: {},
},
parameters:
toString(sub.type) === "custom"
? {
type: "object",
properties: { input: { type: "string" } },
required: ["input"],
additionalProperties: false,
}
: (sub.parameters ??
sub.input_schema ?? {
type: "object",
properties: {},
}),
strict: sub.strict,
},
}));
}

View File

@@ -56,7 +56,8 @@ function mergeNamespaceTools(first: unknown, second: unknown): unknown[] {
* function names from reaching strict upstreams.
*/
export function collectResponsesTools(rootTools: unknown, inputItems: unknown[]): unknown[] {
const sources: unknown[][] = [Array.isArray(rootTools) ? rootTools : []];
const rootToolList = Array.isArray(rootTools) ? rootTools : [];
const sources: unknown[][] = [rootToolList];
for (const itemValue of inputItems) {
const item = toRecord(itemValue);
@@ -64,6 +65,15 @@ export function collectResponsesTools(rootTools: unknown, inputItems: unknown[])
sources.push(item.tools);
}
}
const explicitNames = new Set(
sources
.flat()
.map((tool) => {
const record = toRecord(tool);
return record.type === "namespace" ? "" : toolName(tool);
})
.filter(Boolean)
);
const merged: unknown[] = [];
const seen = new Set<string>();
@@ -74,16 +84,21 @@ export function collectResponsesTools(rootTools: unknown, inputItems: unknown[])
if (toolRecord.type === "namespace") {
const namespaceName = toolName(tool);
const existingIndex = namespaceName ? namespaceIndexes.get(namespaceName) : undefined;
const namespaceTools = Array.isArray(toolRecord.tools)
? toolRecord.tools.filter((member) => !explicitNames.has(toolName(member)))
: toolRecord.tools;
if (existingIndex !== undefined) {
const existing = toRecord(merged[existingIndex]);
merged[existingIndex] = {
...toolRecord,
...existing,
tools: mergeNamespaceTools(existing.tools, toolRecord.tools),
tools: mergeNamespaceTools(existing.tools, namespaceTools),
};
continue;
}
if (namespaceName) namespaceIndexes.set(namespaceName, merged.length);
merged.push({ ...toolRecord, tools: namespaceTools });
continue;
}
const identity = toolIdentity(tool);
@@ -92,5 +107,35 @@ export function collectResponsesTools(rootTools: unknown, inputItems: unknown[])
merged.push(tool);
}
}
return merged;
}
/** Return the custom/freeform tool names after applying the same precedence rules as conversion. */
export function collectResponsesCustomToolNames(
rootTools: unknown,
inputItems: unknown[]
): Set<string> {
const names = new Set<string>();
const visit = (tools: unknown[]) => {
for (const toolValue of tools) {
const tool = toRecord(toolValue);
const name = toolName(toolValue);
if (tool.type === "custom" && name) names.add(name);
if (tool.type === "namespace" && Array.isArray(tool.tools)) visit(tool.tools);
}
};
visit(collectResponsesTools(rootTools, inputItems));
return names;
}
export function collectCustomToolNamesForSourceFormat(
sourceFormat: string,
responsesFormat: string,
rootTools: unknown,
inputItems: unknown[]
): Set<string> {
return sourceFormat === responsesFormat
? collectResponsesCustomToolNames(rootTools, inputItems)
: new Set<string>();
}

View File

@@ -15,6 +15,7 @@ import {
getVisibleResponsesReasoningSummaryText,
} from "./openai-responses/pureHelpers.ts";
import { createEventEmitter } from "./openai-responses/eventEmitter.ts";
import { buildResponsesToolCallItem } from "./responsesToolItem.ts";
import {
synthesizeCompletedToolCalls,
computeFinishReason,
@@ -428,40 +429,38 @@ function emitToolCall(state, emit, tc) {
delete state.funcNames[tcIdx];
delete state.funcArgsBuf[tcIdx];
delete state.funcArgsDone[tcIdx];
delete state.funcItemAdded[tcIdx];
delete state.funcItemDone[tcIdx];
}
if (funcName) state.funcNames[tcIdx] = funcName;
// Codex custom tools (apply_patch) are surfaced to the client as custom_tool_call items
// and stream their raw patch via custom_tool_call_input.* events instead of the
// Custom tools are surfaced as custom_tool_call items and stream raw input instead of the
// function_call_arguments.* events used for regular function tools. (#1007)
const isCustomTool = (state.funcNames[tcIdx] || funcName) === "apply_patch";
const toolName = state.funcNames[tcIdx] || funcName || "";
const isCustomTool =
toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true;
if (!state.funcCallIds[tcIdx] && newCallId) {
state.funcCallIds[tcIdx] = newCallId;
if (!state.funcCallIds[tcIdx] && newCallId) state.funcCallIds[tcIdx] = newCallId;
const callId = state.funcCallIds[tcIdx];
if (callId && toolName && !state.funcItemAdded[tcIdx]) {
emit("response.output_item.added", {
type: "response.output_item.added",
output_index: outputIndex,
item: isCustomTool
? {
id: `fc_${newCallId}`,
type: "custom_tool_call",
input: "",
call_id: newCallId,
name: state.funcNames[tcIdx] || "",
status: "in_progress",
}
: {
id: `fc_${newCallId}`,
type: "function_call",
arguments: "",
call_id: newCallId,
name: state.funcNames[tcIdx] || "",
status: "in_progress",
},
item: buildResponsesToolCallItem({ callId, toolName, custom: isCustomTool }),
});
state.funcItemAdded[tcIdx] = true;
const bufferedArgs = state.funcArgsBuf[tcIdx] || "";
if (bufferedArgs && !isCustomTool) {
emit("response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${callId}`,
output_index: outputIndex,
delta: bufferedArgs,
});
}
}
if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = "";
@@ -474,12 +473,9 @@ function emitToolCall(state, emit, tc) {
const emittedDelta = nextArgs.slice(existingArgs.length);
state.funcArgsBuf[tcIdx] = nextArgs;
if (refCallId && emittedDelta) {
const deltaEvent = isCustomTool
? "response.custom_tool_call_input.delta"
: "response.function_call_arguments.delta";
emit(deltaEvent, {
type: deltaEvent,
if (refCallId && emittedDelta && !isCustomTool && state.funcItemAdded[tcIdx]) {
emit("response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${refCallId}`,
output_index: outputIndex,
delta: emittedDelta,
@@ -495,7 +491,9 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) {
? normalizeOutputIndex(state.reasoningIndex) + 1 + normalizeOutputIndex(idx)
: normalizeOutputIndex(idx);
const args = state.funcArgsBuf[idx] || "{}";
const isCustomTool = (state.funcNames[idx] || "") === "apply_patch";
const toolName = state.funcNames[idx] || "";
const isCustomTool =
toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true;
let funcItem;
if (isCustomTool) {
@@ -509,6 +507,13 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) {
// Not JSON — fall back to the raw buffered arguments.
}
emit("response.custom_tool_call_input.delta", {
type: "response.custom_tool_call_input.delta",
item_id: `fc_${callId}`,
output_index: normalizedIndex,
delta: rawInput,
});
emit("response.custom_tool_call_input.done", {
type: "response.custom_tool_call_input.done",
item_id: `fc_${callId}`,

View File

@@ -0,0 +1,15 @@
export function buildResponsesToolCallItem(options: {
callId: string;
toolName: string;
custom: boolean;
}) {
const { callId, toolName, custom } = options;
return {
id: `fc_${callId}`,
type: custom ? "custom_tool_call" : "function_call",
...(custom ? { input: "" } : { arguments: "" }),
call_id: callId,
name: toolName,
status: "in_progress",
};
}

View File

@@ -133,6 +133,7 @@ type StreamOptions = {
* `RESPONSES_PASSTHROUGH_DROP_COMMENTARY` feature flag (default on).
*/
dropResponsesCommentary?: boolean;
customToolNames?: ReadonlySet<string>;
provider?: string | null;
reqLogger?: StreamLogger | null;
toolNameMap?: unknown;
@@ -159,6 +160,7 @@ type TranslateState = ReturnType<typeof initState> & {
accumulatedReasoning?: string;
/** #6951 — per-tool JSON Schema (from request `tools[]`), keyed by tool name. */
toolSchemas?: Map<string, Record<string, unknown>> | null;
customToolNames?: ReadonlySet<string>;
upstreamError?: {
status: number;
type: string;
@@ -631,6 +633,7 @@ export function createSSEStream(options: StreamOptions = {}) {
onComplete = null,
onFailure = null,
dropResponsesCommentary,
customToolNames = new Set<string>(),
} = options;
const signatureNamespace = connectionId;
// Request-body-size metric (for monitoring payload size distribution & correlation with TTFT).
@@ -705,6 +708,7 @@ export function createSSEStream(options: StreamOptions = {}) {
accumulatedContent: "",
accumulatedReasoning: "",
toolSchemas: extractToolSchemaMap(body),
customToolNames,
}
: null;
@@ -2739,7 +2743,8 @@ export function createSSETransformStreamWithLogger(
apiKeyInfo: unknown = null,
onFailure: ((payload: StreamFailurePayload) => void | Promise<void>) | null = null,
copilotCompatibleReasoning = false,
suppressThinkClose = false
suppressThinkClose = false,
customToolNames: ReadonlySet<string> = new Set()
) {
return createSSEStream({
mode: STREAM_MODE.TRANSLATE,
@@ -2756,6 +2761,7 @@ export function createSSETransformStreamWithLogger(
onFailure,
copilotCompatibleReasoning,
suppressThinkClose,
customToolNames,
});
}

View File

@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { FORMATS } from "../../open-sse/translator/formats.ts";
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-responses-stream-"));
const { createSSETransformStreamWithLogger } = await import("../../open-sse/utils/stream.ts");
test("active Responses stream restores declared custom tool metadata", async () => {
const source = new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({
id: "chatcmpl_custom",
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_exec",
function: { name: "exec", arguments: '{"input":"text(\\"pong\\")"}' },
},
],
},
finish_reason: "tool_calls",
},
],
})}\n\n`
)
);
controller.close();
},
});
const transform = createSSETransformStreamWithLogger(
FORMATS.OPENAI,
FORMATS.OPENAI_RESPONSES,
"opencode-go",
null,
null,
"kimi-k3",
null,
{ messages: [{ role: "user", content: "run it" }] },
null,
null,
null,
false,
false,
new Set(["exec"])
);
const text = await new Response(source.pipeThrough(transform)).text();
assert.match(text, /"type":"custom_tool_call"/);
assert.match(text, /"input":"text\(\\"pong\\"\)"/);
assert.doesNotMatch(text, /"type":"function_call"/);
});

View File

@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
const { openaiResponsesToOpenAIRequest } =
await import("../../open-sse/translator/request/openai-responses.ts");
const { collectCustomToolNamesForSourceFormat, collectResponsesCustomToolNames } =
await import("../../open-sse/translator/request/openai-responses/additionalTools.ts");
interface ChatTool {
function: {
@@ -198,6 +200,132 @@ test("Responses -> Chat merges members from same-named namespaces", () => {
);
});
test("Responses -> Chat gives top-level tools precedence over namespaced members", () => {
const rootTools = [
{
type: "function",
name: "exec",
description: "Explicit function tool",
parameters: { type: "object" },
},
{
type: "namespace",
name: "commands",
tools: [{ type: "custom", name: "exec", description: "Shadowed custom tool" }],
},
];
const result = openaiResponsesToOpenAIRequest(
"any-model",
{
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }],
tools: rootTools,
},
false,
{ provider: "another-provider" }
) as ChatRequest;
assert.deepEqual(
result.tools.map((tool) => tool.function.name),
["exec"]
);
assert.equal(result.tools[0].function.description, "Explicit function tool");
assert.deepEqual([...collectResponsesCustomToolNames(rootTools, [])], []);
});
test("Responses custom metadata includes additional and namespaced custom tools", () => {
const input = [
{
type: "additional_tools",
tools: [
{ type: "custom", name: "exec" },
{
type: "namespace",
name: "server",
tools: [{ type: "custom", name: "apply_diff" }],
},
],
},
];
assert.deepEqual([...collectResponsesCustomToolNames([], input)].sort(), ["apply_diff", "exec"]);
});
test("Responses source format enables custom metadata independently of model apiFormat", () => {
assert.deepEqual(
[
...collectCustomToolNamesForSourceFormat(
"openai-responses",
"openai-responses",
[{ type: "custom", name: "exec" }],
[]
),
],
["exec"]
);
assert.deepEqual(
[
...collectCustomToolNamesForSourceFormat(
"openai",
"openai-responses",
[{ type: "custom", name: "exec" }],
[]
),
],
[]
);
});
test("Responses -> Chat normalizes custom tools nested in namespaces", () => {
const result = openaiResponsesToOpenAIRequest(
"any-model",
{
input: [{ type: "message", role: "user", content: "go" }],
tools: [
{
type: "namespace",
name: "commands",
tools: [{ type: "custom", name: "exec", description: "Run code" }],
},
],
},
false,
{ provider: "another-provider" }
) as ChatRequest;
assert.deepEqual(result.tools[0].function.parameters, {
type: "object",
properties: { input: { type: "string" } },
required: ["input"],
additionalProperties: false,
});
});
test("Responses -> Chat enforces explicit precedence within additional_tools", () => {
const input = [
{
type: "additional_tools",
tools: [
{ type: "function", name: "exec", description: "Explicit function" },
{
type: "namespace",
name: "commands",
tools: [{ type: "custom", name: "exec", description: "Shadowed custom" }],
},
],
},
{ type: "message", role: "user", content: "go" },
];
const result = openaiResponsesToOpenAIRequest("any-model", { input }, false, {
provider: "another-provider",
}) as ChatRequest;
assert.deepEqual(
result.tools.map((tool) => tool.function.name),
["exec"]
);
assert.equal(result.tools[0].function.description, "Explicit function");
assert.deepEqual([...collectResponsesCustomToolNames([], input)], []);
});
test("Responses -> Chat validates tools supplied through additional_tools", () => {
assert.throws(
() =>

View File

@@ -92,6 +92,36 @@ function buildOpenAISseResponse(text = "hello") {
);
}
function buildToolCallSseResponse(name: string, argumentsJson: string) {
return new Response(
[
`data: ${JSON.stringify({
id: "chatcmpl-tool",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name, arguments: argumentsJson },
},
],
},
finish_reason: "tool_calls",
},
],
})}`,
"",
"data: [DONE]",
"",
].join("\n"),
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
);
}
function buildJsonResponse(status: number, payload: unknown) {
return new Response(JSON.stringify(payload), {
status,
@@ -343,6 +373,77 @@ test("handleResponsesCore rejects invalid Responses API input that cannot be tra
);
});
test("handleResponsesCore restores custom tools declared through additional_tools", async () => {
const { result, call } = await invokeResponsesCore({
body: {
model: "gpt-4o-mini",
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "ping" }] },
{
type: "additional_tools",
tools: [{ type: "custom", name: "exec", description: "Execute freeform code" }],
},
],
},
responseFactory: () => buildToolCallSseResponse("exec", '{"input":"text(\\"pong\\")"}'),
});
assert.equal(call.body.tools[0].type, "function");
const sse = await result.response.text();
assert.match(sse, /"type":"custom_tool_call"/);
assert.match(sse, /"input":"text\(\\"pong\\"\)"/);
assert.doesNotMatch(sse, /"type":"function_call","arguments"/);
});
test("handleResponsesCore preserves top-level tool precedence for custom-name collisions", async () => {
const { result } = await invokeResponsesCore({
body: {
model: "gpt-4o-mini",
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "ping" }] },
{
type: "additional_tools",
tools: [{ type: "custom", name: "exec", description: "Shadowed custom tool" }],
},
],
tools: [
{
type: "function",
name: "exec",
description: "Explicit function tool",
parameters: { type: "object", properties: {} },
},
],
},
responseFactory: () => buildToolCallSseResponse("exec", "{}"),
});
const sse = await result.response.text();
assert.match(sse, /"type":"function_call"/);
assert.doesNotMatch(sse, /"type":"custom_tool_call"/);
});
test("handleResponsesCore restores custom tools nested in namespaces", async () => {
const { result } = await invokeResponsesCore({
body: {
model: "gpt-4o-mini",
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "ping" }] }],
tools: [
{
type: "namespace",
name: "commands",
tools: [{ type: "custom", name: "exec", description: "Execute freeform code" }],
},
],
},
responseFactory: () => buildToolCallSseResponse("exec", '{"input":"pong"}'),
});
const sse = await result.response.text();
assert.match(sse, /"type":"custom_tool_call"/);
assert.doesNotMatch(sse, /"type":"function_call","arguments"/);
});
test("handleResponsesCore injects SSE keepalive frames for Responses streams", async (t) => {
// PR #2233 changed the Responses-API heartbeat shape from a SSE comment
// (`: keepalive ...`) to a `data: {"type":"response.in_progress"}` frame,

View File

@@ -10,8 +10,8 @@ const { createResponsesApiTransformStream, createResponsesLogger } =
const encoder = new TextEncoder();
const decoder = new TextDecoder();
async function runTransformStream(chunks, logger = null) {
const stream = createResponsesApiTransformStream(logger);
async function runTransformStream(chunks, logger = null, options = {}) {
const stream = createResponsesApiTransformStream(logger, 3000, options);
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
@@ -175,6 +175,109 @@ test("createResponsesApiTransformStream handles native reasoning content and too
);
});
test("createResponsesApiTransformStream restores declared custom tools without changing functions", async () => {
const output = await runTransformStream(
[
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","function":{"name":"exec","arguments":"{\\"input\\":\\"text(\\\\\\"pong\\\\\\")\\"}"}}]}}]}\n\n',
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_search","function":{"name":"search","arguments":"{\\"q\\":\\"pong\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n',
],
null,
{ customToolNames: new Set(["exec"]) }
);
const events = parseSseOutput(output);
const added = events
.filter((event) => event.event === "response.output_item.added")
.map((event) => JSON.parse(event.data).item);
const completed = JSON.parse(
events.find((event) => event.event === "response.completed").data
).response;
assert.equal(added.find((item) => item.name === "exec").type, "custom_tool_call");
assert.equal(added.find((item) => item.name === "search").type, "function_call");
assert.ok(events.some((event) => event.event === "response.custom_tool_call_input.delta"));
assert.ok(events.some((event) => event.event === "response.custom_tool_call_input.done"));
assert.equal(completed.output.find((item) => item.name === "exec").input, 'text("pong")');
assert.equal(completed.output.find((item) => item.name === "search").arguments, '{"q":"pong"}');
});
test("createResponsesApiTransformStream preserves empty custom-tool input", async () => {
const output = await runTransformStream(
[
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_empty","function":{"name":"exec","arguments":"{\\"input\\":\\"\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n',
],
null,
{ customToolNames: new Set(["exec"]) }
);
const events = parseSseOutput(output);
const done = events
.filter((event) => event.event === "response.output_item.done")
.map((event) => JSON.parse(event.data).item)
.find((item) => item.name === "exec");
assert.equal(done.type, "custom_tool_call");
assert.equal(done.input, "");
});
test("createResponsesApiTransformStream defers custom item creation until the tool name arrives", async () => {
const output = await runTransformStream(
[
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec"}]}}]}\n\n',
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"exec","arguments":"{\\"input\\":\\"pong\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n',
],
null,
{ customToolNames: new Set(["exec"]) }
);
const events = parseSseOutput(output);
const added = events
.filter((event) => event.event === "response.output_item.added")
.map((event) => JSON.parse(event.data).item)
.filter((item) => item.call_id === "call_exec");
assert.deepEqual(added, [
{
id: "fc_call_exec",
type: "custom_tool_call",
input: "",
call_id: "call_exec",
name: "exec",
status: "in_progress",
},
]);
assert.equal(events.filter((event) => event.event === "response.output_item.done").length, 1);
});
test("createResponsesApiTransformStream replays buffered function arguments after the name arrives", async () => {
const output = await runTransformStream([
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_search","function":{"arguments":"{\\"q\\":\\"pong\\"}"}}]}}]}\n\n',
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"search"}}]},"finish_reason":"tool_calls"}]}\n\n',
]);
const events = parseSseOutput(output);
const argumentDeltas = events
.filter((event) => event.event === "response.function_call_arguments.delta")
.map((event) => JSON.parse(event.data).delta);
assert.deepEqual(argumentDeltas, ['{"q":"pong"}']);
});
test("createResponsesApiTransformStream preserves empty custom-tool input", async () => {
const output = await runTransformStream(
[
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","function":{"name":"exec","arguments":"{\\"input\\":\\"\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n',
],
null,
{ customToolNames: new Set(["exec"]) }
);
const events = parseSseOutput(output);
const completed = JSON.parse(
events.find((event) => event.event === "response.completed").data
).response;
assert.equal(completed.output.find((item) => item.name === "exec").input, "");
});
test("createResponsesLogger persists input and output event logs on flush", async () => {
const logsDir = mkdtempSync(join(tmpdir(), "responses-transformer-"));
const logger = createResponsesLogger("gpt-4o", logsDir);

View File

@@ -8,8 +8,9 @@ const { openaiToOpenAIResponsesResponse } =
const { initState } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
function collectEvents(chunks) {
function collectEvents(chunks, customToolNames = new Set()) {
const state = initState(FORMATS.OPENAI_RESPONSES);
state.customToolNames = customToolNames;
const events = [];
for (const chunk of chunks) {
const result = openaiToOpenAIResponsesResponse(chunk, state);
@@ -164,3 +165,82 @@ test("OpenAI -> Responses: apply_patch streams as custom_tool_call with raw inpu
assert.ok(customItem, "final snapshot should carry the custom_tool_call item");
assert.equal(customItem.input, "PATCH_BODY");
});
test("OpenAI -> Responses: declared custom tools round-trip through the active translator", () => {
const events = collectEvents(
[
{
id: "chatcmpl-exec",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_exec",
function: { name: "exec", arguments: '{"input":"text(\\"pong\\")"}' },
},
],
},
finish_reason: "tool_calls",
},
],
},
null,
],
new Set(["exec"])
);
const added = events.find((event) => event.event === "response.output_item.added");
const done = events.find(
(event) => event.event === "response.output_item.done" && event.data.item.name === "exec"
);
assert.equal(added.data.item.type, "custom_tool_call");
assert.equal(done.data.item.type, "custom_tool_call");
assert.equal(done.data.item.input, 'text("pong")');
assert.ok(events.some((event) => event.event === "response.custom_tool_call_input.delta"));
assert.ok(!events.some((event) => event.event === "response.function_call_arguments.delta"));
});
test("OpenAI -> Responses: active translator defers custom item until its name arrives", () => {
const events = collectEvents(
[
{
id: "chatcmpl-late-name",
choices: [
{
index: 0,
delta: {
tool_calls: [
{ index: 0, id: "call_exec", function: { arguments: '{"input":"pong"}' } },
],
},
},
],
},
{
id: "chatcmpl-late-name",
choices: [
{
index: 0,
delta: { tool_calls: [{ index: 0, function: { name: "exec" } }] },
finish_reason: "tool_calls",
},
],
},
null,
],
new Set(["exec"])
);
const added = events.filter((event) => event.event === "response.output_item.added");
assert.equal(added.length, 1);
assert.equal(added[0].data.item.type, "custom_tool_call");
assert.equal(added[0].data.item.name, "exec");
assert.ok(!events.some((event) => event.data?.item?.type === "function_call"));
const done = events.find(
(event) => event.event === "response.output_item.done" && event.data.item.name === "exec"
);
assert.equal(done.data.item.input, "pong");
});