mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -684,6 +684,7 @@ export function initState(sourceFormat) {
|
||||
funcNames: {},
|
||||
funcCallIds: {},
|
||||
funcArgsDone: {},
|
||||
funcItemAdded: {},
|
||||
funcItemDone: {},
|
||||
completedOutputItems: [],
|
||||
completedSent: false,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
15
open-sse/translator/response/responsesToolItem.ts
Normal file
15
open-sse/translator/response/responsesToolItem.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
63
tests/unit/responses-active-stream-custom-tool.test.ts
Normal file
63
tests/unit/responses-active-stream-custom-tool.test.ts
Normal 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"/);
|
||||
});
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user