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 { proxyAwareFetch } from "../utils/proxyFetch.js";
import { dbg } from "../utils/debugLog.js"; import { dbg } from "../utils/debugLog.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.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 * BaseExecutor - Base class for provider executors
@ -30,7 +31,7 @@ export class BaseExecutor {
if (this.provider?.startsWith?.("openai-compatible-")) { if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE; const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, ""); 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}`; return `${normalized}${path}`;
} }
if (this.provider?.startsWith?.("anthropic-compatible-")) { if (this.provider?.startsWith?.("anthropic-compatible-")) {

View file

@ -1,6 +1,7 @@
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.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 { 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 { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js";
import { buildClineHeaders } from "../shared/clineAuth.js"; import { buildClineHeaders } from "../shared/clineAuth.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
@ -109,7 +110,7 @@ export class DefaultExecutor extends BaseExecutor {
if (this.provider?.startsWith?.("openai-compatible-")) { if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE; const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, ""); 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}`; return `${normalized}${path}`;
} }
if (this.provider?.startsWith?.("anthropic-compatible-")) { 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 {object} options.credentials - Provider credentials
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses") * @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 }) { 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 { provider, model } = modelInfo;
const requestStartTime = Date.now(); const requestStartTime = Date.now();
@ -60,7 +80,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const modelTargetFormat = getModelTargetFormat(alias, model); const modelTargetFormat = getModelTargetFormat(alias, model);
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation // Multi-endpoint providers: pick transport matching sourceFormat → zero translation
const runtimeTransport = resolveTransport(provider, sourceFormat); 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; if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
const stripList = getModelStrip(alias, model); const stripList = getModelStrip(alias, model);
const upstreamModel = getModelUpstreamId(alias, model); const upstreamModel = getModelUpstreamId(alias, model);
@ -133,6 +153,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
let translatedBody; let translatedBody;
let toolNameMap; let toolNameMap;
let customToolNames;
if (passthrough) { if (passthrough) {
log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`); log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`);
translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) }; translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) };
@ -158,7 +179,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
} }
toolNameMap = translatedBody._toolNameMap; toolNameMap = translatedBody._toolNameMap;
delete translatedBody._toolNameMap; delete translatedBody._toolNameMap;
customToolNames = translatedBody._customToolNames;
delete translatedBody._customToolNames;
translatedBody.model = stripThinkingSuffix(upstreamModel); translatedBody.model = stripThinkingSuffix(upstreamModel);
stripContinuityFields(translatedBody);
} }
// Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only). // 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 // Provider forced streaming but client wants JSON
if (!clientRequestedStreaming && providerRequiresStreaming) { 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; } if (result) { streamController.handleComplete(); return result; }
} }
// True non-streaming response // True non-streaming response
if (!stream) { 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(); streamController.handleComplete();
return result; return result;
} }
// Streaming response // Streaming response
const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx }); 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) { 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 { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { decloakToolNames } from "../../utils/claudeCloaking.js"; import { decloakToolNames } from "../../utils/claudeCloaking.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
function parseToolArguments(value) { function parseToolArguments(value) {
if (!value) return {}; 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. * 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; 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) { if (targetFormat === FORMATS.OPENAI && sourceFormat === FORMATS.CLAUDE) {
return openAICompletionToClaudeMessage(responseBody); return openAICompletionToClaudeMessage(responseBody);
} }
@ -198,7 +281,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
/** /**
* Handle non-streaming response from provider. * 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(); trackDone();
const contentType = providerResponse.headers.get("content-type") || ""; const contentType = providerResponse.headers.get("content-type") || "";
let responseBody; 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 } })); if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
const translatedResponse = needsTranslation(targetFormat, sourceFormat) const translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames)
: responseBody; : responseBody;
const isClaudeMessageResponse = sourceFormat === FORMATS.CLAUDE && translatedResponse?.type === "message"; 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") // Fix finish_reason for tool_calls: some providers return non-standard values (e.g. "other")
if (translatedResponse?.choices?.[0]) { if (translatedResponse?.choices?.[0]) {
@ -254,13 +340,13 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
} }
// Ensure OpenAI-required fields // Ensure OpenAI-required fields
if (!isClaudeMessageResponse) { if (!isClaudeMessageResponse && !isResponsesResponse) {
if (!translatedResponse.object) translatedResponse.object = "chat.completion"; if (!translatedResponse.object) translatedResponse.object = "chat.completion";
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000); if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
} }
// Strip Azure-specific fields // Strip Azure-specific fields
if (!isClaudeMessageResponse) { if (!isClaudeMessageResponse && !isResponsesResponse) {
delete translatedResponse.prompt_filter_results; delete translatedResponse.prompt_filter_results;
if (translatedResponse?.choices) { if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) delete choice.content_filter_results; 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. // Strip reasoning_content only when content is non-empty.
// When content is empty (e.g. thinking models that used all tokens for reasoning), // 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. // 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) { for (const choice of translatedResponse.choices) {
if (choice?.message?.reasoning_content && choice.message.content) { if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_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 { FORMATS } from "../../translator/formats.js";
import { PROVIDERS } from "../../config/providers.js"; import { PROVIDERS } from "../../config/providers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.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 // 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; const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
@ -34,6 +35,76 @@ function pickAssistantMessageForChatCompletion(output) {
return { msgItem: last, textContent: textFromResponsesMessageItem(last) }; 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. * Parse OpenAI-style SSE text into a single chat completion JSON.
* Used when provider forces streaming but client wants non-streaming. * 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. * Handle case: provider forced streaming but client wants JSON.
* Supports both Codex/Responses API SSE and standard Chat Completions SSE. * 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 contentType = providerResponse.headers.get("content-type") || "";
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider)); const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
if (!isSSE) return null; // not handled here if (!isSSE) return null; // not handled here
@ -122,7 +193,10 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
}; };
// Codex/Responses API SSE path // 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) { if (isCodexResponsesApi) {
try { try {
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body); 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) { } catch (err) {
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err); console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON"); 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. * 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"); const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format // Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES; const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
@ -30,11 +30,11 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
if (needsCodexTranslation) { if (needsCodexTranslation) {
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI; 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)) { 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); 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. * 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) { if (onRequestSuccess) {
Promise.resolve() Promise.resolve()
.then(onRequestSuccess) .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 // 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; 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); return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
} }
function getOpenAICompatibleType(provider) { // Resolve the API type (chat vs responses) for an openai-compatible node.
if (!isOpenAICompatible(provider)) return "chat"; // The stored apiType on the connection's providerSpecificData (kept in sync with
return provider.includes("responses") ? "responses" : "chat"; // 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 // Detect request format from body structure
@ -105,9 +111,9 @@ export function detectFormat(body) {
} }
// Get provider config (internal — no external runtime consumer) // Get provider config (internal — no external runtime consumer)
function getProviderConfig(provider) { function getProviderConfig(provider, credentials = null) {
if (isOpenAICompatible(provider)) { if (isOpenAICompatible(provider)) {
const apiType = getOpenAICompatibleType(provider); const apiType = resolveOpenAICompatibleApiType(provider, credentials);
return { return {
...PROVIDERS.openai, ...PROVIDERS.openai,
format: apiType === "responses" ? "openai-responses" : "openai", format: apiType === "responses" ? "openai-responses" : "openai",
@ -125,14 +131,14 @@ function getProviderConfig(provider) {
} }
// Get target format for provider // Get target format for provider
export function getTargetFormat(provider) { export function getTargetFormat(provider, credentials = null) {
if (isOpenAICompatible(provider)) { if (isOpenAICompatible(provider)) {
return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai"; return resolveOpenAICompatibleApiType(provider, credentials) === "responses" ? "openai-responses" : "openai";
} }
if (isAnthropicCompatible(provider)) { if (isAnthropicCompatible(provider)) {
return "claude"; return "claude";
} }
const config = getProviderConfig(provider); const config = getProviderConfig(provider, credentials);
return config.format || "openai"; return config.format || "openai";
} }

View file

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

View file

@ -32,6 +32,8 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
let pendingToolResults = []; let pendingToolResults = [];
let pendingReasoning = ""; let pendingReasoning = "";
let pendingReasoningEncrypted = ""; let pendingReasoningEncrypted = "";
const additionalTools = [];
const customToolNames = new Set();
const inputItems = normalizeResponsesInput(body.input); const inputItems = normalizeResponsesInput(body.input);
if (!inputItems) return body; if (!inputItems) return body;
@ -96,7 +98,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
} }
result.messages.push(msg); 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 // Start or append to assistant message with tool_calls
if (!currentAssistantMsg) { if (!currentAssistantMsg) {
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) // 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 (!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({ currentAssistantMsg.tool_calls.push({
id: item.call_id, id: item.call_id,
type: OPENAI_BLOCK.FUNCTION, type: OPENAI_BLOCK.FUNCTION,
function: { function: {
name: item.name, 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 // Flush assistant message first if exists
if (currentAssistantMsg) { if (currentAssistantMsg) {
result.messages.push(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) 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) { else if (itemType === RESPONSES_ITEM.REASONING) {
// Buffer reasoning text; attached to next assistant message/function_call. // Buffer reasoning text; attached to next assistant message/function_call.
// Also stash encrypted_content so a later openai→responses hop can restore // 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. // explicit `name` field and cannot be represented as Chat Completions function declarations.
// Filter them out to avoid sending nameless functionDeclarations to downstream providers // Filter them out to avoid sending nameless functionDeclarations to downstream providers
// such as Gemini, which strictly validates function names. // such as Gemini, which strictly validates function names.
if (body.tools && Array.isArray(body.tools)) { const responseTools = [
result.tools = body.tools ...(Array.isArray(body.tools) ? body.tools : []),
...additionalTools,
];
if (responseTools.length > 0) {
result.tools = responseTools
.map(tool => { .map(tool => {
// Already in Chat Completions format: { type: "function", function: { name, ... } } // Already in Chat Completions format: { type: "function", function: { name, ... } }
if (tool.function) return tool; if (tool.function) return tool;
// Responses API function tool: { type: "function", name, description, parameters } // Responses API function/custom tool: { type, name, description, parameters|format }.
// Only convert when a non-empty name is present; skip hosted tools without one. // 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; const name = tool.name;
if (!name || typeof name !== "string" || name.trim() === "") return null; 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 { return {
type: OPENAI_BLOCK.FUNCTION, type: OPENAI_BLOCK.FUNCTION,
function: { function: {
@ -187,6 +226,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
}) })
.filter(Boolean); .filter(Boolean);
} }
if (customToolNames.size > 0) result._customToolNames = [...customToolNames];
// Cleanup Responses API specific fields // Cleanup Responses API specific fields
// Map Responses-only max_output_tokens to Chat max_tokens (avoid leaking unknown field upstream) // 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) { function emitToolCall(state, emit, tc) {
const tcIdx = tc.index ?? 0; const tcIdx = tc.index ?? 0;
const newCallId = tc.id; const newCallId = tc.id;
const funcName = tc.function?.name; const funcName = tc.function?.name;
if (funcName) state.funcNames[tcIdx] = funcName; 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", { emit("response.output_item.added", {
type: "response.output_item.added", type: "response.output_item.added",
output_index: tcIdx, output_index: tcIdx,
item: { item: {
id: `fc_${newCallId}`, id: `${custom ? "ctc" : "fc"}_${callId}`,
type: RESPONSES_ITEM.FUNCTION_CALL, type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
arguments: "", ...(custom ? { input: "" } : { arguments: "" }),
call_id: newCallId, call_id: callId,
name: state.funcNames[tcIdx] || "" name: state.funcNames[tcIdx] || ""
} }
}); });
@ -285,7 +304,7 @@ function emitToolCall(state, emit, tc) {
if (tc.function?.arguments) { if (tc.function?.arguments) {
const refCallId = state.funcCallIds[tcIdx] || newCallId; const refCallId = state.funcCallIds[tcIdx] || newCallId;
if (refCallId) { if (state.funcItemAdded[tcIdx] && refCallId && !isCustomTool(state, state.funcNames[tcIdx])) {
emit("response.function_call_arguments.delta", { emit("response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta", type: "response.function_call_arguments.delta",
item_id: `fc_${refCallId}`, item_id: `fc_${refCallId}`,
@ -293,6 +312,9 @@ function emitToolCall(state, emit, tc) {
delta: tc.function.arguments 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; state.funcArgsBuf[tcIdx] += tc.function.arguments;
} }
} }
@ -301,21 +323,38 @@ function closeToolCall(state, emit, idx) {
const callId = state.funcCallIds[idx]; const callId = state.funcCallIds[idx];
if (callId && !state.funcItemDone[idx]) { if (callId && !state.funcItemDone[idx]) {
const args = state.funcArgsBuf[idx] || "{}"; const args = state.funcArgsBuf[idx] || "{}";
const custom = isCustomTool(state, state.funcNames[idx]);
emit("response.function_call_arguments.done", {
type: "response.function_call_arguments.done", if (custom) {
item_id: `fc_${callId}`, const input = extractCustomToolInput(args);
output_index: parseInt(idx), emit("response.custom_tool_call_input.delta", {
arguments: args 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", { emit("response.output_item.done", {
type: "response.output_item.done", type: "response.output_item.done",
output_index: parseInt(idx), output_index: parseInt(idx),
item: { item: {
id: `fc_${callId}`, id: `${custom ? "ctc" : "fc"}_${callId}`,
type: RESPONSES_ITEM.FUNCTION_CALL, type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
arguments: args, ...(custom ? { input: extractCustomToolInput(args) } : { arguments: args }),
call_id: callId, call_id: callId,
name: state.funcNames[idx] || "" name: state.funcNames[idx] || ""
} }

View file

@ -27,6 +27,9 @@ export const RESPONSES_ITEM = {
MESSAGE: "message", MESSAGE: "message",
FUNCTION_CALL: "function_call", FUNCTION_CALL: "function_call",
FUNCTION_CALL_OUTPUT: "function_call_output", 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", REASONING: "reasoning",
OUTPUT_TEXT: "output_text", OUTPUT_TEXT: "output_text",
INPUT_TEXT: "input_text", INPUT_TEXT: "input_text",

View file

@ -44,6 +44,7 @@ export function createSSEStream(options = {}) {
provider = null, provider = null,
reqLogger = null, reqLogger = null,
toolNameMap = null, toolNameMap = null,
customToolNames = null,
model = null, model = null,
connectionId = null, connectionId = null,
body = 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 // Per-stream decoder with stream:true to correctly handle multi-byte chars split across chunks
const decoder = new TextDecoder("utf-8", { fatal: false }); 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 totalContentLength = 0;
let accumulatedContent = ""; 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({ return createSSEStream({
mode: STREAM_MODE.TRANSLATE, mode: STREAM_MODE.TRANSLATE,
targetFormat, targetFormat,
@ -472,6 +475,7 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
provider, provider,
reqLogger, reqLogger,
toolNameMap, toolNameMap,
customToolNames,
model, model,
connectionId, connectionId,
body, body,

View file

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