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:
@@ -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-")) {
|
||||
|
||||
@@ -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-")) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -258,8 +258,10 @@ export function initState(sourceFormat) {
|
||||
funcArgsBuf: {},
|
||||
funcNames: {},
|
||||
funcCallIds: {},
|
||||
funcItemAdded: {},
|
||||
funcArgsDone: {},
|
||||
funcItemDone: {},
|
||||
customToolNames: new Set(),
|
||||
completedSent: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] || ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } }],
|
||||
});
|
||||
|
||||
80
tests/unit/continuity-strip.test.js
Normal file
80
tests/unit/continuity-strip.test.js
Normal 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"]);
|
||||
});
|
||||
});
|
||||
76
tests/unit/openai-compatible-apitype-resolution.test.js
Normal file
76
tests/unit/openai-compatible-apitype-resolution.test.js
Normal 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`);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
153
tests/unit/openai-responses-custom-tools.test.js
Normal file
153
tests/unit/openai-responses-custom-tools.test.js
Normal 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\"}",
|
||||
});
|
||||
});
|
||||
});
|
||||
148
tests/unit/openai-responses-nonstream.test.js
Normal file
148
tests/unit/openai-responses-nonstream.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user