fix(translator): preserve Responses Lite tools across Chat providers

Codex Responses Lite clients routed to a chat-native OpenAI-compatible
provider lost tool use in three places: non-streaming Chat responses
leaked the raw chat.completion envelope instead of Responses output
items, internal reasoning continuity fields leaked into the outbound
Chat body causing some upstreams to reject the request, and the
Responses to Chat request translator ignored additional_tools,
custom_tool_call, and custom_tool_call_output items entirely.

Also fixes apiType (chat vs responses) for openai-compatible nodes
being resolved from the immutable provider ID instead of the stored
node config, so editing a node's API Type had no runtime effect.
This commit is contained in:
nguyenha935
2026-08-05 13:23:03 +07:00
committed by decolua
parent b11be8be0a
commit d06e0d26c6
17 changed files with 803 additions and 58 deletions

View File

@@ -3,6 +3,7 @@ import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js"
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { dbg } from "../utils/debugLog.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
import { resolveOpenAICompatibleApiType } from "../services/provider.js";
/**
* BaseExecutor - Base class for provider executors
@@ -30,7 +31,7 @@ export class BaseExecutor {
if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, "");
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
const path = resolveOpenAICompatibleApiType(this.provider, credentials) === "responses" ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {

View File

@@ -1,6 +1,7 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE, selectAnthropicBeta } from "../providers/shared.js";
import { resolveOpenAICompatibleApiType } from "../services/provider.js";
import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js";
import { buildClineHeaders } from "../shared/clineAuth.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
@@ -109,7 +110,7 @@ export class DefaultExecutor extends BaseExecutor {
if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, "");
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
const path = resolveOpenAICompatibleApiType(this.provider, credentials) === "responses" ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {

View File

@@ -37,6 +37,26 @@ import { resolveSessionId } from "../utils/sessionManager.js";
* @param {object} options.credentials - Provider credentials
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
*/
/**
* Remove translator-internal continuity fields from the outbound upstream
* body. The Responses→Chat request translator stashes reasoning
* `encrypted_content` on assistant messages so a later openai→responses
* round-trip can restore the store=false continuity blob; that stash must
* never reach an upstream provider. Chat-native proxies reject the unknown
* assistant-message field and answer every turn with a literal "400" body
* (observed with multi-turn Codex sessions via OpenAI-compatible nodes).
*/
export function stripContinuityFields(body) {
if (!body || !Array.isArray(body.messages)) return body;
for (const msg of body.messages) {
if (msg && typeof msg === "object") {
delete msg.encrypted_content;
delete msg.reasoning_encrypted_content;
}
}
return body;
}
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) {
const { provider, model } = modelInfo;
const requestStartTime = Date.now();
@@ -60,7 +80,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const modelTargetFormat = getModelTargetFormat(alias, model);
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation
const runtimeTransport = resolveTransport(provider, sourceFormat);
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider);
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials);
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
const stripList = getModelStrip(alias, model);
const upstreamModel = getModelUpstreamId(alias, model);
@@ -133,6 +153,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
let translatedBody;
let toolNameMap;
let customToolNames;
if (passthrough) {
log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`);
translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) };
@@ -158,7 +179,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
toolNameMap = translatedBody._toolNameMap;
delete translatedBody._toolNameMap;
customToolNames = translatedBody._customToolNames;
delete translatedBody._customToolNames;
translatedBody.model = stripThinkingSuffix(upstreamModel);
stripContinuityFields(translatedBody);
}
// Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only).
@@ -406,20 +430,20 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Provider forced streaming but client wants JSON
if (!clientRequestedStreaming && providerRequiresStreaming) {
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog });
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, trackDone, appendLog });
if (result) { streamController.handleComplete(); return result; }
}
// True non-streaming response
if (!stream) {
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, trackDone, appendLog });
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, customToolNames, trackDone, appendLog });
streamController.handleComplete();
return result;
}
// Streaming response
const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx });
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId });
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId });
}
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {

View File

@@ -9,6 +9,7 @@ import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { decloakToolNames } from "../../utils/claudeCloaking.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
function parseToolArguments(value) {
if (!value) return {};
@@ -60,11 +61,93 @@ function openAICompletionToClaudeMessage(responseBody) {
};
}
/**
* Convert an OpenAI Chat Completions non-streaming response body into the
* OpenAI Responses API shape. Used when a Responses-format client (e.g. Codex)
* is routed to a Chat Completions upstream and `stream:false` — the streaming
* path already emits Responses events, but the JSON path returned a raw
* `chat.completion` body, so tool_calls were invisible to Responses clients.
*/
function extractCustomToolInput(argumentsValue) {
const argumentsText = typeof argumentsValue === "string" ? argumentsValue : JSON.stringify(argumentsValue || {});
try {
const parsed = JSON.parse(argumentsText);
if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input;
} catch { /* raw freeform input */ }
return argumentsText;
}
function openAICompletionToResponses(responseBody, customToolNames = null) {
const choice = responseBody?.choices?.[0];
if (!choice) return responseBody;
const message = choice.message || {};
const output = [];
// Reasoning → a reasoning item (summary text), mirroring the streaming path.
const reasoning = message.reasoning_content || message.reasoning;
if (typeof reasoning === "string" && reasoning.length > 0) {
output.push({
type: RESPONSES_ITEM.REASONING,
summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: reasoning }],
});
}
// Assistant text → a message item with output_text content.
const text = typeof message.content === "string" ? message.content : "";
if (text.length > 0) {
output.push({
type: RESPONSES_ITEM.MESSAGE,
role: ROLE.ASSISTANT,
content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, text, annotations: [] }],
});
}
// tool_calls → function_call/custom_tool_call items (Responses-native tool shape).
for (const tc of message.tool_calls || []) {
const fn = tc.function || {};
const custom = customToolNames?.has(fn.name);
output.push({
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
id: `${custom ? "ctc" : "fc"}_${tc.id || ""}`,
call_id: tc.id || "",
name: fn.name || "",
...(custom
? { input: extractCustomToolInput(fn.arguments) }
: { arguments: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments || {}) }),
});
}
const usage = responseBody.usage || {};
const status = choice.finish_reason === "tool_calls" ? "completed" : (choice.finish_reason === "stop" ? "completed" : (choice.finish_reason || "completed"));
return {
id: `resp_${responseBody.id || ""}`.replace(/^resp_chatcmpl-/, "resp_"),
object: "response",
created_at: responseBody.created || Math.floor(Date.now() / 1000),
model: responseBody.model || "unknown",
status,
background: false,
error: null,
output,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0),
},
};
}
/**
* Translate non-streaming response body from provider format → OpenAI format.
*/
export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) {
export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames = null) {
if (targetFormat === sourceFormat) return responseBody;
// Provider responded in OpenAI Chat Completions shape but the client speaks
// Responses API — convert so tool_calls/text surface as Responses `output`.
if (targetFormat === FORMATS.OPENAI && sourceFormat === FORMATS.OPENAI_RESPONSES) {
return openAICompletionToResponses(responseBody, customToolNames);
}
if (targetFormat === FORMATS.OPENAI && sourceFormat === FORMATS.CLAUDE) {
return openAICompletionToClaudeMessage(responseBody);
}
@@ -198,7 +281,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
/**
* Handle non-streaming response from provider.
*/
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog, pxpipe, reqTag, log }) {
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, trackDone, appendLog, pxpipe, reqTag, log }) {
trackDone();
const contentType = providerResponse.headers.get("content-type") || "";
let responseBody;
@@ -239,9 +322,12 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames)
: responseBody;
const isClaudeMessageResponse = sourceFormat === FORMATS.CLAUDE && translatedResponse?.type === "message";
// Responses-format translation produces a `object:"response"` body with no
// `choices`; skip the Chat-Completions-specific post-processing below for it.
const isResponsesResponse = sourceFormat === FORMATS.OPENAI_RESPONSES && translatedResponse?.object === "response";
// Fix finish_reason for tool_calls: some providers return non-standard values (e.g. "other")
if (translatedResponse?.choices?.[0]) {
@@ -254,13 +340,13 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
}
// Ensure OpenAI-required fields
if (!isClaudeMessageResponse) {
if (!isClaudeMessageResponse && !isResponsesResponse) {
if (!translatedResponse.object) translatedResponse.object = "chat.completion";
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
}
// Strip Azure-specific fields
if (!isClaudeMessageResponse) {
if (!isClaudeMessageResponse && !isResponsesResponse) {
delete translatedResponse.prompt_filter_results;
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) delete choice.content_filter_results;
@@ -274,7 +360,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
// Strip reasoning_content only when content is non-empty.
// When content is empty (e.g. thinking models that used all tokens for reasoning),
// reasoning_content is the only useful output and must be preserved.
if (!isClaudeMessageResponse && translatedResponse?.choices) {
if (!isClaudeMessageResponse && !isResponsesResponse && translatedResponse?.choices) {
for (const choice of translatedResponse.choices) {
if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_content;

View File

@@ -4,6 +4,7 @@ import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { FORMATS } from "../../translator/formats.js";
import { PROVIDERS } from "../../config/providers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
// Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape
const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
@@ -34,6 +35,76 @@ function pickAssistantMessageForChatCompletion(output) {
return { msgItem: last, textContent: textFromResponsesMessageItem(last) };
}
/**
* Convert an OpenAI Chat Completions JSON body into the Responses API shape.
* Inlined here (not imported from nonStreamingHandler.js) to avoid a circular
* import. Mirrors openAICompletionToResponses in nonStreamingHandler.js.
*/
function extractCustomToolInput(argumentsValue) {
const argumentsText = typeof argumentsValue === "string" ? argumentsValue : JSON.stringify(argumentsValue || {});
try {
const parsed = JSON.parse(argumentsText);
if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input;
} catch { /* raw freeform input */ }
return argumentsText;
}
function chatCompletionToResponses(responseBody, customToolNames = null) {
const choice = responseBody?.choices?.[0];
if (!choice) return responseBody;
const message = choice.message || {};
const output = [];
const reasoning = message.reasoning_content || message.reasoning;
if (typeof reasoning === "string" && reasoning.length > 0) {
output.push({
type: RESPONSES_ITEM.REASONING,
summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: reasoning }],
});
}
const text = typeof message.content === "string" ? message.content : "";
if (text.length > 0) {
output.push({
type: RESPONSES_ITEM.MESSAGE,
role: ROLE.ASSISTANT,
content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, text, annotations: [] }],
});
}
for (const tc of message.tool_calls || []) {
const fn = tc.function || {};
const custom = customToolNames?.has(fn.name);
output.push({
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
id: `${custom ? "ctc" : "fc"}_${tc.id || ""}`,
call_id: tc.id || "",
name: fn.name || "",
...(custom
? { input: extractCustomToolInput(fn.arguments) }
: { arguments: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments || {}) }),
});
}
const usage = responseBody.usage || {};
return {
id: `resp_${responseBody.id || ""}`.replace(/^resp_chatcmpl-/, "resp_"),
object: "response",
created_at: responseBody.created || Math.floor(Date.now() / 1000),
model: responseBody.model || "unknown",
status: "completed",
background: false,
error: null,
output,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0),
},
};
}
/**
* Parse OpenAI-style SSE text into a single chat completion JSON.
* Used when provider forces streaming but client wants non-streaming.
@@ -108,7 +179,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
* Handle case: provider forced streaming but client wants JSON.
* Supports both Codex/Responses API SSE and standard Chat Completions SSE.
*/
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog, reqTag, log }) {
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, targetFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, customToolNames, trackDone, appendLog, reqTag, log }) {
const contentType = providerResponse.headers.get("content-type") || "";
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
if (!isSSE) return null; // not handled here
@@ -122,7 +193,10 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
};
// Codex/Responses API SSE path
const isCodexResponsesApi = isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES;
// Branch on the UPSTREAM format (targetFormat = format we spoke to the provider in),
// not the client format: a Responses-API client behind a chat-native forced-streaming
// provider still receives chat SSE chunks, which must go through the standard path.
const isCodexResponsesApi = isResponsesProvider(provider) || targetFormat === FORMATS.OPENAI_RESPONSES;
if (isCodexResponsesApi) {
try {
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body);
@@ -267,7 +341,17 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
}
}
return { success: true, response: new Response(JSON.stringify(parsed), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
// A Responses-format client (e.g. Codex) forced this provider to stream,
// but wants JSON back. parseSSEToOpenAIResponse yields a Chat Completions
// body; convert it to the Responses `output` shape so tool_calls are not
// lost on the non-streaming return path. Inlined (not imported from
// nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler
// already imports parseSSEToOpenAIResponse from this module.
const finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
? chatCompletionToResponses(parsed, customToolNames)
: parsed;
return { success: true, response: new Response(JSON.stringify(finalBody), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
} catch (err) {
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");

View File

@@ -22,7 +22,7 @@ const CODEX_SOURCE_TO_TARGET = {
/**
* Determine which SSE transform stream to use based on provider/format.
*/
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) {
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey }) {
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
@@ -30,11 +30,11 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
if (needsCodexTranslation) {
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
}
if (needsTranslation(targetFormat, sourceFormat)) {
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
}
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
@@ -43,7 +43,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
/**
* Handle streaming response — pipe provider SSE through transform stream to client.
*/
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log }) {
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log }) {
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
@@ -79,7 +79,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
};
}
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey });
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey });
// Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;

View File

@@ -19,9 +19,15 @@ function isAnthropicCompatible(provider) {
return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
}
function getOpenAICompatibleType(provider) {
if (!isOpenAICompatible(provider)) return "chat";
return provider.includes("responses") ? "responses" : "chat";
// Resolve the API type (chat vs responses) for an openai-compatible node.
// The stored apiType on the connection's providerSpecificData (kept in sync with
// the node on create/update) is authoritative. Falls back to the node ID
// substring for legacy nodes created before apiType was persisted — their IDs
// embed the type: openai-compatible-<chat|responses>-<uuid>.
export function resolveOpenAICompatibleApiType(provider, credentials = null) {
const stored = credentials?.providerSpecificData?.apiType;
if (stored === "chat" || stored === "responses") return stored;
return typeof provider === "string" && provider.includes("responses") ? "responses" : "chat";
}
// Detect request format from body structure
@@ -105,9 +111,9 @@ export function detectFormat(body) {
}
// Get provider config (internal — no external runtime consumer)
function getProviderConfig(provider) {
function getProviderConfig(provider, credentials = null) {
if (isOpenAICompatible(provider)) {
const apiType = getOpenAICompatibleType(provider);
const apiType = resolveOpenAICompatibleApiType(provider, credentials);
return {
...PROVIDERS.openai,
format: apiType === "responses" ? "openai-responses" : "openai",
@@ -125,14 +131,14 @@ function getProviderConfig(provider) {
}
// Get target format for provider
export function getTargetFormat(provider) {
export function getTargetFormat(provider, credentials = null) {
if (isOpenAICompatible(provider)) {
return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai";
return resolveOpenAICompatibleApiType(provider, credentials) === "responses" ? "openai-responses" : "openai";
}
if (isAnthropicCompatible(provider)) {
return "claude";
}
const config = getProviderConfig(provider);
const config = getProviderConfig(provider, credentials);
return config.format || "openai";
}

View File

@@ -258,8 +258,10 @@ export function initState(sourceFormat) {
funcArgsBuf: {},
funcNames: {},
funcCallIds: {},
funcItemAdded: {},
funcArgsDone: {},
funcItemDone: {},
customToolNames: new Set(),
completedSent: false
};
}

View File

@@ -32,6 +32,8 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
let pendingToolResults = [];
let pendingReasoning = "";
let pendingReasoningEncrypted = "";
const additionalTools = [];
const customToolNames = new Set();
const inputItems = normalizeResponsesInput(body.input);
if (!inputItems) return body;
@@ -96,7 +98,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
}
result.messages.push(msg);
}
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) {
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL || itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL) {
// Start or append to assistant message with tool_calls
if (!currentAssistantMsg) {
currentAssistantMsg = {
@@ -108,16 +110,20 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
}
// Skip items with empty/missing name — Codex/OpenAI reject nameless tool calls (#444)
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue;
if (itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL) customToolNames.add(item.name);
const toolInput = itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL
? { input: typeof item.input === "string" ? item.input : JSON.stringify(item.input ?? "") }
: item.arguments;
currentAssistantMsg.tool_calls.push({
id: item.call_id,
type: OPENAI_BLOCK.FUNCTION,
function: {
name: item.name,
arguments: item.arguments
arguments: typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput ?? {})
}
});
}
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) {
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT || itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL_OUTPUT) {
// Flush assistant message first if exists
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
@@ -137,6 +143,9 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
content: typeof item.output === "string" ? item.output : JSON.stringify(item.output)
});
}
else if (itemType === RESPONSES_ITEM.ADDITIONAL_TOOLS) {
if (Array.isArray(item.tools)) additionalTools.push(...item.tools);
}
else if (itemType === RESPONSES_ITEM.REASONING) {
// Buffer reasoning text; attached to next assistant message/function_call.
// Also stash encrypted_content so a later openai→responses hop can restore
@@ -166,15 +175,45 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
// explicit `name` field and cannot be represented as Chat Completions function declarations.
// Filter them out to avoid sending nameless functionDeclarations to downstream providers
// such as Gemini, which strictly validates function names.
if (body.tools && Array.isArray(body.tools)) {
result.tools = body.tools
const responseTools = [
...(Array.isArray(body.tools) ? body.tools : []),
...additionalTools,
];
if (responseTools.length > 0) {
result.tools = responseTools
.map(tool => {
// Already in Chat Completions format: { type: "function", function: { name, ... } }
if (tool.function) return tool;
// Responses API function tool: { type: "function", name, description, parameters }
// Only convert when a non-empty name is present; skip hosted tools without one.
// Responses API function/custom tool: { type, name, description, parameters|format }.
// Chat Completions has no freeform custom-tool declaration, so expose custom
// tools as functions with one raw `input` string while retaining their names
// in translator-only metadata for the response conversion.
const name = tool.name;
if (!name || typeof name !== "string" || name.trim() === "") return null;
if (tool.type === "custom") {
customToolNames.add(name);
const formatHint = [tool.format?.syntax, tool.format?.definition].filter(Boolean).join("\n");
return {
type: OPENAI_BLOCK.FUNCTION,
function: {
name,
description: [String(tool.description || ""), formatHint].filter(Boolean).join("\n\n"),
parameters: {
type: "object",
properties: {
input: {
type: "string",
description: "Raw freeform input for this custom tool"
}
},
required: ["input"],
additionalProperties: false
}
}
};
}
// Responses API function tool: { type: "function", name, description, parameters }
// Only convert when a non-empty name is present; skip hosted tools without one.
return {
type: OPENAI_BLOCK.FUNCTION,
function: {
@@ -187,6 +226,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
})
.filter(Boolean);
}
if (customToolNames.size > 0) result._customToolNames = [...customToolNames];
// Cleanup Responses API specific fields
// Map Responses-only max_output_tokens to Chat max_tokens (avoid leaking unknown field upstream)

View File

@@ -258,24 +258,43 @@ function closeMessage(state, emit, idx) {
}
}
function isCustomTool(state, name) {
return !!name && state.customToolNames?.has(name);
}
function extractCustomToolInput(argumentsText) {
if (typeof argumentsText !== "string") return "";
try {
const parsed = JSON.parse(argumentsText);
if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input;
} catch { /* incomplete or raw freeform input */ }
return argumentsText;
}
function emitToolCall(state, emit, tc) {
const tcIdx = tc.index ?? 0;
const newCallId = tc.id;
const funcName = tc.function?.name;
if (funcName) state.funcNames[tcIdx] = funcName;
if (newCallId) state.funcCallIds[tcIdx] = newCallId;
// Some compatible providers split the call id and function name across
// chunks. Wait for both before deciding whether this is a custom tool;
// otherwise an `exec` call can be irreversibly announced as function_call.
const callId = state.funcCallIds[tcIdx];
if (!state.funcItemAdded[tcIdx] && callId && state.funcNames[tcIdx]) {
state.funcItemAdded[tcIdx] = true;
const custom = isCustomTool(state, state.funcNames[tcIdx]);
if (!state.funcCallIds[tcIdx] && newCallId) {
state.funcCallIds[tcIdx] = newCallId;
emit("response.output_item.added", {
type: "response.output_item.added",
output_index: tcIdx,
item: {
id: `fc_${newCallId}`,
type: RESPONSES_ITEM.FUNCTION_CALL,
arguments: "",
call_id: newCallId,
id: `${custom ? "ctc" : "fc"}_${callId}`,
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
...(custom ? { input: "" } : { arguments: "" }),
call_id: callId,
name: state.funcNames[tcIdx] || ""
}
});
@@ -285,7 +304,7 @@ function emitToolCall(state, emit, tc) {
if (tc.function?.arguments) {
const refCallId = state.funcCallIds[tcIdx] || newCallId;
if (refCallId) {
if (state.funcItemAdded[tcIdx] && refCallId && !isCustomTool(state, state.funcNames[tcIdx])) {
emit("response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${refCallId}`,
@@ -293,6 +312,9 @@ function emitToolCall(state, emit, tc) {
delta: tc.function.arguments
});
}
// Custom input is emitted once at close, after the Chat JSON wrapper can be
// parsed and unwrapped. Streaming the raw JSON fragments would expose
// {"input":"..."} instead of the freeform program Codex expects.
state.funcArgsBuf[tcIdx] += tc.function.arguments;
}
}
@@ -301,21 +323,38 @@ function closeToolCall(state, emit, idx) {
const callId = state.funcCallIds[idx];
if (callId && !state.funcItemDone[idx]) {
const args = state.funcArgsBuf[idx] || "{}";
emit("response.function_call_arguments.done", {
type: "response.function_call_arguments.done",
item_id: `fc_${callId}`,
output_index: parseInt(idx),
arguments: args
});
const custom = isCustomTool(state, state.funcNames[idx]);
if (custom) {
const input = extractCustomToolInput(args);
emit("response.custom_tool_call_input.delta", {
type: "response.custom_tool_call_input.delta",
item_id: `ctc_${callId}`,
output_index: parseInt(idx),
delta: input
});
emit("response.custom_tool_call_input.done", {
type: "response.custom_tool_call_input.done",
item_id: `ctc_${callId}`,
output_index: parseInt(idx),
input
});
} else {
emit("response.function_call_arguments.done", {
type: "response.function_call_arguments.done",
item_id: `fc_${callId}`,
output_index: parseInt(idx),
arguments: args
});
}
emit("response.output_item.done", {
type: "response.output_item.done",
output_index: parseInt(idx),
item: {
id: `fc_${callId}`,
type: RESPONSES_ITEM.FUNCTION_CALL,
arguments: args,
id: `${custom ? "ctc" : "fc"}_${callId}`,
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
...(custom ? { input: extractCustomToolInput(args) } : { arguments: args }),
call_id: callId,
name: state.funcNames[idx] || ""
}

View File

@@ -27,6 +27,9 @@ export const RESPONSES_ITEM = {
MESSAGE: "message",
FUNCTION_CALL: "function_call",
FUNCTION_CALL_OUTPUT: "function_call_output",
CUSTOM_TOOL_CALL: "custom_tool_call",
CUSTOM_TOOL_CALL_OUTPUT: "custom_tool_call_output",
ADDITIONAL_TOOLS: "additional_tools",
REASONING: "reasoning",
OUTPUT_TEXT: "output_text",
INPUT_TEXT: "input_text",

View File

@@ -44,6 +44,7 @@ export function createSSEStream(options = {}) {
provider = null,
reqLogger = null,
toolNameMap = null,
customToolNames = null,
model = null,
connectionId = null,
body = null,
@@ -57,7 +58,9 @@ export function createSSEStream(options = {}) {
// Per-stream decoder with stream:true to correctly handle multi-byte chars split across chunks
const decoder = new TextDecoder("utf-8", { fatal: false });
const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model } : null;
const state = mode === STREAM_MODE.TRANSLATE
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model }
: null;
let totalContentLength = 0;
let accumulatedContent = "";
@@ -464,7 +467,7 @@ export function createSSEStream(options = {}) {
});
}
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) {
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null) {
return createSSEStream({
mode: STREAM_MODE.TRANSLATE,
targetFormat,
@@ -472,6 +475,7 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
provider,
reqLogger,
toolNameMap,
customToolNames,
model,
connectionId,
body,

View File

@@ -20,9 +20,7 @@ describe("Codex CLI Responses → OpenAI", () => {
expect(asst?.tool_calls?.length ?? 0, "empty tool_calls[] produced").toBeGreaterThan(0);
});
// openai-responses.js:109-110 — arguments passed through without ensuring string type
// KNOWN BUG
it.fails("function_call arguments end up as a string", () => {
it("function_call arguments end up as a string", () => {
const out = R2O({
input: [{ type: "function_call", call_id: "c1", name: "f", arguments: { a: 1 } }],
});

View File

@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/usageDb.js", () => ({
appendRequestLog: vi.fn(async () => {}),
saveRequestDetail: vi.fn(async () => {}),
saveRequestUsage: vi.fn(async () => {})
}));
const { stripContinuityFields } = await import("../../open-sse/handlers/chatCore.js");
const { openaiResponsesToOpenAIRequest } = await import("../../open-sse/translator/request/openai-responses.js");
// Multi-turn Codex-style Responses input: reasoning item carrying a
// store=false encrypted_content continuity blob between tool turns.
const makeResponsesBody = () => ({
model: "x",
instructions: "You are Codex.",
store: false,
include: ["reasoning.encrypted_content"],
reasoning: { effort: "low", summary: "auto" },
input: [
{ role: "user", content: [{ type: "input_text", text: "Run: echo hi" }] },
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "thinking" }],
encrypted_content: "B".repeat(5000)
},
{ type: "function_call", id: "fc_1", call_id: "call_1", name: "shell", arguments: "{\"command\":\"echo hi\"}" },
{ type: "function_call_output", call_id: "call_1", output: "hi" },
{ role: "user", content: [{ type: "input_text", text: "Now run: echo bye" }] }
],
tools: [{
type: "function",
name: "shell",
description: "Run a shell command",
parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] }
}]
});
describe("stripContinuityFields (outbound boundary)", () => {
it("removes continuity blobs from assistant messages", () => {
const body = {
messages: [
{ role: "user", content: "hi" },
{ role: "assistant", content: null, reasoning_content: "thinking",
encrypted_content: "B".repeat(500), reasoning_encrypted_content: "alias",
tool_calls: [{ id: "call_1", type: "function", function: { name: "shell", arguments: "{}" } }] }
]
};
stripContinuityFields(body);
const assistant = body.messages[1];
expect(assistant).not.toHaveProperty("encrypted_content");
expect(assistant).not.toHaveProperty("reasoning_encrypted_content");
// legitimate fields survive
expect(assistant.reasoning_content).toBe("thinking");
expect(assistant.tool_calls[0].function.name).toBe("shell");
});
it("is a no-op for bodies without a messages array", () => {
const body = { input: [], instructions: "x" };
expect(stripContinuityFields(body)).toBe(body);
expect(stripContinuityFields(null)).toBe(null);
});
it("end-to-end: Responses multi-turn translation stripped before dispatch", () => {
const translated = openaiResponsesToOpenAIRequest("x", makeResponsesBody(), false, {});
// The translator stashes the blob for internal round-trip symmetry...
const assistantBefore = translated.messages.find((m) => m.role === "assistant");
expect(assistantBefore.encrypted_content).toBe("B".repeat(5000));
// ...and the outbound boundary removes it before it reaches any upstream.
stripContinuityFields(translated);
expect(JSON.stringify(translated)).not.toContain("encrypted_content");
expect(JSON.stringify(translated)).not.toContain("B".repeat(100));
const assistant = translated.messages.find((m) => m.role === "assistant");
expect(assistant.reasoning_content).toContain("thinking");
expect(assistant.tool_calls[0].function.name).toBe("shell");
const roles = translated.messages.map((m) => m.role);
expect(roles).toEqual(["system", "user", "assistant", "tool", "user"]);
});
});

View File

@@ -0,0 +1,76 @@
// Locks openai-compatible apiType resolution: the stored apiType on the
// connection's providerSpecificData (kept in sync with the node) is
// authoritative, and the node-ID substring is only a legacy fallback.
//
// Regression for: editing a node's apiType to "responses" had no effect because
// runtime derived chat/responses from the immutable node ID string
// (`openai-compatible-<chat|responses>-<uuid>`) instead of the stored value.
import { describe, it, expect } from "vitest";
import { resolveOpenAICompatibleApiType, getTargetFormat } from "open-sse/services/provider.js";
import { DefaultExecutor } from "open-sse/executors/default.js";
import { BaseExecutor } from "open-sse/executors/base.js";
const CHAT_ID = "openai-compatible-chat-3d8d3de8-1206-47ee-a42f-22113a5f2387";
const RESPONSES_ID = "openai-compatible-responses-11111111-2222-3333-4444-555555555555";
const BASE = "https://api.ericding.io.vn/v1";
function creds(apiType) {
return { providerSpecificData: apiType === undefined ? { baseUrl: BASE } : { baseUrl: BASE, apiType } };
}
describe("resolveOpenAICompatibleApiType", () => {
it("prefers stored apiType over the ID substring (edited node on a legacy -chat- ID)", () => {
expect(resolveOpenAICompatibleApiType(CHAT_ID, creds("responses"))).toBe("responses");
expect(resolveOpenAICompatibleApiType(RESPONSES_ID, creds("chat"))).toBe("chat");
});
it("falls back to the ID substring when apiType is absent", () => {
expect(resolveOpenAICompatibleApiType(CHAT_ID, creds(undefined))).toBe("chat");
expect(resolveOpenAICompatibleApiType(RESPONSES_ID, creds(undefined))).toBe("responses");
expect(resolveOpenAICompatibleApiType(CHAT_ID, null)).toBe("chat");
expect(resolveOpenAICompatibleApiType(RESPONSES_ID, null)).toBe("responses");
});
it("ignores an invalid stored apiType and falls back to the ID", () => {
expect(resolveOpenAICompatibleApiType(RESPONSES_ID, creds("bogus"))).toBe("responses");
expect(resolveOpenAICompatibleApiType(CHAT_ID, creds(""))).toBe("chat");
});
});
describe("getTargetFormat", () => {
it("selects openai-responses when the stored apiType is responses (even on a -chat- ID)", () => {
expect(getTargetFormat(CHAT_ID, creds("responses"))).toBe("openai-responses");
});
it("selects openai for chat, and honors stored chat over a -responses- ID", () => {
expect(getTargetFormat(CHAT_ID, creds(undefined))).toBe("openai");
expect(getTargetFormat(RESPONSES_ID, creds("chat"))).toBe("openai");
});
it("keeps the ID-based fallback when credentials are absent", () => {
expect(getTargetFormat(RESPONSES_ID)).toBe("openai-responses");
expect(getTargetFormat(CHAT_ID)).toBe("openai");
});
});
describe("executor buildUrl endpoint path", () => {
for (const [name, Ex] of [["DefaultExecutor", DefaultExecutor], ["BaseExecutor", BaseExecutor]]) {
describe(name, () => {
const ex = new Ex(CHAT_ID);
it("routes to /responses when stored apiType is responses, despite the -chat- ID", () => {
expect(ex.buildUrl("cx/gpt-5.6-sol", true, 0, creds("responses"))).toBe(`${BASE}/responses`);
});
it("routes to /chat/completions when apiType is chat", () => {
expect(ex.buildUrl("cx/gpt-5.6-sol", true, 0, creds("chat"))).toBe(`${BASE}/chat/completions`);
});
it("falls back to the ID substring (legacy) when apiType is absent", () => {
expect(ex.buildUrl("cx/gpt-5.6-sol", true, 0, creds(undefined))).toBe(`${BASE}/chat/completions`);
const exResp = new Ex(RESPONSES_ID);
expect(exResp.buildUrl("m", true, 0, creds(undefined))).toBe(`${BASE}/responses`);
});
});
}
});

View File

@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import {
openaiResponsesToOpenAIRequest,
} from "../../open-sse/translator/request/openai-responses.js";
import { openaiToOpenAIResponsesResponse } from "../../open-sse/translator/response/openai-responses.js";
import { initState } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const EXEC_TOOL = {
type: "custom",
name: "exec",
description: "Run JavaScript code to orchestrate tool calls.",
format: {
type: "grammar",
syntax: "lark",
definition: "start: /(.|\\n)+/",
},
};
describe("Codex Responses Lite custom tools → OpenAI Chat", () => {
it("promotes additional_tools custom declarations into Chat tools", () => {
const out = openaiResponsesToOpenAIRequest("cx/gpt-5.6-sol", {
input: [
{ type: "additional_tools", role: "developer", tools: [EXEC_TOOL] },
{ type: "message", role: "user", content: [{ type: "input_text", text: "Run pwd" }] },
],
tool_choice: "auto",
}, true, null);
expect(out.tools).toHaveLength(1);
expect(out.tools[0]).toMatchObject({
type: "function",
function: {
name: "exec",
parameters: {
type: "object",
required: ["input"],
properties: { input: { type: "string" } },
},
},
});
expect(out._customToolNames).toEqual(["exec"]);
expect(out.messages.some((message) => message.role === "developer")).toBe(false);
});
it("translates custom tool call/output history into Chat assistant/tool messages", () => {
const program = "const result = await tools.shell({command: 'pwd'});\nreturn result;";
const out = openaiResponsesToOpenAIRequest("cx/gpt-5.6-sol", {
input: [
{ type: "additional_tools", role: "developer", tools: [EXEC_TOOL] },
{ type: "custom_tool_call", call_id: "call_exec_1", name: "exec", input: program },
{ type: "custom_tool_call_output", call_id: "call_exec_1", output: "/srv/app" },
{ type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
}, true, null);
const assistant = out.messages.find((message) => message.role === "assistant");
expect(assistant.tool_calls[0]).toMatchObject({
id: "call_exec_1",
type: "function",
function: { name: "exec" },
});
expect(JSON.parse(assistant.tool_calls[0].function.arguments)).toEqual({ input: program });
expect(out.messages.find((message) => message.role === "tool")).toEqual({
role: "tool",
tool_call_id: "call_exec_1",
content: "/srv/app",
});
});
it("merges additional_tools with normal top-level function tools", () => {
const out = openaiResponsesToOpenAIRequest("cx/gpt-5.6-sol", {
input: [{ type: "additional_tools", role: "developer", tools: [EXEC_TOOL] }],
tools: [{ type: "function", name: "search", parameters: { type: "object", properties: {} } }],
}, true, null);
expect(out.tools.map((tool) => tool.function.name)).toEqual(["search", "exec"]);
expect(out._customToolNames).toEqual(["exec"]);
});
});
describe("OpenAI Chat stream → Codex custom_tool_call", () => {
it("unwraps the Chat input parameter and emits custom-tool events", () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
state.customToolNames = new Set(["exec"]);
const chunks = [
{
id: "chatcmpl-custom",
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_exec_2", type: "function", function: { name: "exec", arguments: "" } }] }, finish_reason: null }],
},
{
id: "chatcmpl-custom",
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: "{\"input\":\"const x = await tools.shell({command: 'pwd'});\"}" } }] }, finish_reason: null }],
},
{ id: "chatcmpl-custom", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
];
const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
const added = events.find((event) => event.event === "response.output_item.added");
const delta = events.find((event) => event.event === "response.custom_tool_call_input.delta");
const done = events.find((event) => event.event === "response.output_item.done");
expect(added.data.item).toMatchObject({
type: "custom_tool_call",
call_id: "call_exec_2",
name: "exec",
input: "",
});
expect(delta.data.delta).toBe("const x = await tools.shell({command: 'pwd'});");
expect(done.data.item).toMatchObject({
type: "custom_tool_call",
call_id: "call_exec_2",
name: "exec",
input: "const x = await tools.shell({command: 'pwd'});",
});
expect(events.some((event) => event.event === "response.function_call_arguments.delta")).toBe(false);
});
it("waits for the function name when id and name arrive in separate chunks", () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
state.customToolNames = new Set(["exec"]);
const chunks = [
{ id: "chatcmpl-split", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_split", type: "function", function: { arguments: "" } }] }, finish_reason: null }] },
{ id: "chatcmpl-split", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { name: "exec", arguments: "{\"input\":\"return 1;\"}" } }] }, finish_reason: null }] },
{ id: "chatcmpl-split", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
];
const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
const added = events.filter((event) => event.event === "response.output_item.added");
expect(added).toHaveLength(1);
expect(added[0].data.item).toMatchObject({
type: "custom_tool_call",
call_id: "call_split",
name: "exec",
});
});
it("leaves normal Chat tool calls as Responses function_call events", () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
state.customToolNames = new Set(["exec"]);
const events = [
{ id: "chatcmpl-normal", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_search", type: "function", function: { name: "search", arguments: "{\"q\":\"x\"}" } }] }, finish_reason: null }] },
{ id: "chatcmpl-normal", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
].flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
expect(events.find((event) => event.event === "response.output_item.added").data.item.type).toBe("function_call");
expect(events.find((event) => event.event === "response.output_item.done").data.item).toMatchObject({
type: "function_call",
name: "search",
arguments: "{\"q\":\"x\"}",
});
});
});

View File

@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/usageDb.js", () => ({
appendRequestLog: vi.fn(async () => {}),
saveRequestDetail: vi.fn(async () => {}),
saveRequestUsage: vi.fn(async () => {})
}));
const { FORMATS } = await import("../../open-sse/translator/formats.js");
const { translateNonStreamingResponse } = await import("../../open-sse/handlers/chatCore/nonStreamingHandler.js");
const { handleForcedSSEToJson } = await import("../../open-sse/handlers/chatCore/sseToJsonHandler.js");
// A chat.completion body as returned by a chat-native upstream (e.g. op-ericding)
const CHAT_TOOL_BODY = {
id: "chatcmpl-abc123",
object: "chat.completion",
created: 1700000000,
model: "cl/claude-haiku-4-5",
choices: [{
index: 0,
message: {
role: "assistant",
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "shell", arguments: "{\"cmd\":\"ls\"}" } }]
},
finish_reason: "tool_calls"
}],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
};
describe("non-stream Chat upstream for a Responses-API client (op-ericding bug)", () => {
it("translates chat.completion tool_calls into Responses function_call output", () => {
// translateNonStreamingResponse(body, targetFormat=PROVIDER format, sourceFormat=CLIENT format)
const out = translateNonStreamingResponse(CHAT_TOOL_BODY, FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES);
expect(out.object).toBe("response");
expect(out).not.toHaveProperty("choices");
const fc = (out.output || []).find((o) => o.type === "function_call");
expect(fc).toBeTruthy();
expect(fc.call_id).toBe("call_1");
expect(fc.name).toBe("shell");
expect(fc.arguments).toBe("{\"cmd\":\"ls\"}");
});
it("translates marked Chat tools into Responses custom_tool_call output", () => {
const customBody = structuredClone(CHAT_TOOL_BODY);
customBody.choices[0].message.tool_calls[0] = {
id: "call_exec",
type: "function",
function: {
name: "exec",
arguments: "{\"input\":\"return await tools.shell({command: 'pwd'});\"}"
}
};
const out = translateNonStreamingResponse(
customBody,
FORMATS.OPENAI,
FORMATS.OPENAI_RESPONSES,
new Set(["exec"])
);
const call = (out.output || []).find((item) => item.type === "custom_tool_call");
expect(call).toMatchObject({
call_id: "call_exec",
name: "exec",
input: "return await tools.shell({command: 'pwd'});"
});
expect(out.output.some((item) => item.type === "function_call")).toBe(false);
});
it("keeps chat.completion text content as a Responses message item", () => {
const body = {
...CHAT_TOOL_BODY,
choices: [{ index: 0, message: { role: "assistant", content: "hello" }, finish_reason: "stop" }]
};
const out = translateNonStreamingResponse(body, FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES);
const msg = (out.output || []).find((o) => o.type === "message");
expect(msg).toBeTruthy();
expect(msg.content[0].type).toBe("output_text");
expect(msg.content[0].text).toBe("hello");
});
it("leaves chat->chat untouched", () => {
const out = translateNonStreamingResponse(CHAT_TOOL_BODY, FORMATS.OPENAI, FORMATS.OPENAI);
expect(out.object).toBe("chat.completion");
expect(out.choices[0].message.tool_calls[0].function.name).toBe("shell");
});
});
describe("forced-SSE JSON path for a Responses-API client behind a chat upstream", () => {
const sseCtx = (sourceFormat, targetFormat) => {
const encoder = new TextEncoder();
const raw = [
'data: {"id":"chatcmpl-sse","object":"chat.completion.chunk","created":1700000000,"model":"gpt-x","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_9","type":"function","function":{"name":"shell","arguments":""}}]},"finish_reason":null}]}',
'data: {"id":"chatcmpl-sse","object":"chat.completion.chunk","created":1700000000,"model":"gpt-x","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"cmd\\":\\"pwd\\"}"}}]},"finish_reason":null}]}',
'data: {"id":"chatcmpl-sse","object":"chat.completion.chunk","created":1700000000,"model":"gpt-x","choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
"data: [DONE]",
""
].join("\n\n");
return {
providerResponse: new Response(new ReadableStream({
start(controller) { controller.enqueue(encoder.encode(raw)); controller.close(); }
}), { headers: { "content-type": "text/event-stream" } }),
sourceFormat,
targetFormat,
provider: "op-test-chat",
model: "gpt-x",
body: { model: "gpt-x", messages: [] },
stream: false,
requestStartTime: Date.now(),
connectionId: "test-connection",
clientRawRequest: { endpoint: "/v1/responses" },
trackDone: vi.fn(),
appendLog: vi.fn()
};
};
it("parses chat SSE chunks and returns a Responses function_call body", async () => {
const result = await handleForcedSSEToJson(sseCtx(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI));
expect(result.success).toBe(true);
const json = await result.response.json();
expect(json.object).toBe("response");
const fc = (json.output || []).find((o) => o.type === "function_call");
expect(fc).toBeTruthy();
expect(fc.name).toBe("shell");
expect(fc.arguments).toBe("{\"cmd\":\"pwd\"}");
});
it("returns a custom_tool_call for a marked tool", async () => {
const ctx = sseCtx(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI);
ctx.customToolNames = new Set(["shell"]);
const result = await handleForcedSSEToJson(ctx);
expect(result.success).toBe(true);
const json = await result.response.json();
const call = (json.output || []).find((item) => item.type === "custom_tool_call");
expect(call).toMatchObject({
call_id: "call_9",
name: "shell",
input: "{\"cmd\":\"pwd\"}"
});
});
it("still returns chat.completion for a plain chat client", async () => {
const result = await handleForcedSSEToJson(sseCtx(FORMATS.OPENAI, FORMATS.OPENAI));
expect(result.success).toBe(true);
const json = await result.response.json();
expect(json.object).toBe("chat.completion");
expect(json.choices[0].message.tool_calls[0].function.name).toBe("shell");
});
});