diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index f1f8139b..a4c017e4 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -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-")) { diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index cbd1ccce..eb000f37 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -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-")) { diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index c32b74bb..9f17cf94 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -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) { diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index f93058fd..becc0842 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -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; diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 91cff8ac..6801be89 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -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"); diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 1fb8a322..be008e7d 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -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; diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 1b02cd83..50f0589d 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -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--. +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"; } diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 48bd1530..e2f45339 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -258,8 +258,10 @@ export function initState(sourceFormat) { funcArgsBuf: {}, funcNames: {}, funcCallIds: {}, + funcItemAdded: {}, funcArgsDone: {}, funcItemDone: {}, + customToolNames: new Set(), completedSent: false }; } diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index ecfa26d6..b821f3a4 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -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) diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index b9336785..92e3cb52 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -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] || "" } diff --git a/open-sse/translator/schema/blocks.js b/open-sse/translator/schema/blocks.js index 61c25645..958122ce 100644 --- a/open-sse/translator/schema/blocks.js +++ b/open-sse/translator/schema/blocks.js @@ -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", diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 464acde4..33e7fb04 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -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, diff --git a/tests/translator/bugs-codexCli-responses.test.js b/tests/translator/bugs-codexCli-responses.test.js index c3401341..573b5eb0 100644 --- a/tests/translator/bugs-codexCli-responses.test.js +++ b/tests/translator/bugs-codexCli-responses.test.js @@ -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 } }], }); diff --git a/tests/unit/continuity-strip.test.js b/tests/unit/continuity-strip.test.js new file mode 100644 index 00000000..c80d5945 --- /dev/null +++ b/tests/unit/continuity-strip.test.js @@ -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"]); + }); +}); diff --git a/tests/unit/openai-compatible-apitype-resolution.test.js b/tests/unit/openai-compatible-apitype-resolution.test.js new file mode 100644 index 00000000..0199ed4d --- /dev/null +++ b/tests/unit/openai-compatible-apitype-resolution.test.js @@ -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--`) 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`); + }); + }); + } +}); diff --git a/tests/unit/openai-responses-custom-tools.test.js b/tests/unit/openai-responses-custom-tools.test.js new file mode 100644 index 00000000..9145243c --- /dev/null +++ b/tests/unit/openai-responses-custom-tools.test.js @@ -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\"}", + }); + }); +}); diff --git a/tests/unit/openai-responses-nonstream.test.js b/tests/unit/openai-responses-nonstream.test.js new file mode 100644 index 00000000..a588a9da --- /dev/null +++ b/tests/unit/openai-responses-nonstream.test.js @@ -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"); + }); +});