diff --git a/cli.js b/cli.js
index 7f2c5a62..a2ae2ec2 100644
--- a/cli.js
+++ b/cli.js
@@ -2561,6 +2561,28 @@ function buildClaudeSettingsDiff(params = {}) {
};
}
+
+function normalizeOpenaiBridgeMaxRetries(value, fallback = 2) {
+ const raw = Number(value);
+ const fallbackRaw = Number(fallback);
+ const base = Number.isFinite(raw) ? raw : (Number.isFinite(fallbackRaw) ? fallbackRaw : 2);
+ return Math.min(10, Math.max(2, Math.floor(base)));
+}
+
+function resolveProviderOpenaiBridgeMaxRetries(provider) {
+ if (!provider || typeof provider !== 'object') return 2;
+ if (provider.codexmate_bridge_max_retries !== undefined) {
+ return normalizeOpenaiBridgeMaxRetries(provider.codexmate_bridge_max_retries);
+ }
+ if (provider.openai_bridge_max_retries !== undefined) {
+ return normalizeOpenaiBridgeMaxRetries(provider.openai_bridge_max_retries);
+ }
+ if (provider.max_retries !== undefined) {
+ return normalizeOpenaiBridgeMaxRetries(provider.max_retries);
+ }
+ return 2;
+}
+
function addProviderToConfig(params = {}) {
const name = typeof params.name === 'string' ? params.name.trim() : '';
const url = typeof params.url === 'string' ? params.url.trim() : '';
@@ -2575,6 +2597,8 @@ function addProviderToConfig(params = {}) {
? params.model.trim()
: fallbackModel;
const useTransform = !!params.useTransform;
+ const hasOpenaiBridgeMaxRetries = params.openaiBridgeMaxRetries !== undefined || params.maxRetries !== undefined;
+ const openaiBridgeMaxRetries = normalizeOpenaiBridgeMaxRetries(params.openaiBridgeMaxRetries ?? params.maxRetries);
const allowManaged = !!params.allowManaged;
const normalizedUrl = normalizeBaseUrl(url);
@@ -2634,7 +2658,7 @@ function addProviderToConfig(params = {}) {
const requiresOpenaiAuth = useTransform;
if (useTransform) {
- const saveRes = upsertOpenaiBridgeProvider(OPENAI_BRIDGE_SETTINGS_FILE, name, normalizedUrl, key);
+ const saveRes = upsertOpenaiBridgeProvider(OPENAI_BRIDGE_SETTINGS_FILE, name, normalizedUrl, key, undefined, { maxRetries: openaiBridgeMaxRetries });
if (saveRes && saveRes.error) {
return { error: String(saveRes.error) };
}
@@ -2646,6 +2670,7 @@ function addProviderToConfig(params = {}) {
).toString().replace(/\/+$/g, '');
authKeyForConfig = 'codexmate';
extraLines.push(`codexmate_bridge = "openai"`);
+ extraLines.push(`codexmate_bridge_max_retries = ${openaiBridgeMaxRetries}`);
}
const safeUrl = escapeTomlBasicString(baseUrlForConfig);
@@ -2689,6 +2714,8 @@ function updateProviderInConfig(params = {}) {
? String(params.key).trim()
: undefined;
const useTransform = !!params.useTransform;
+ const hasOpenaiBridgeMaxRetries = params.openaiBridgeMaxRetries !== undefined || params.maxRetries !== undefined;
+ const openaiBridgeMaxRetries = normalizeOpenaiBridgeMaxRetries(params.openaiBridgeMaxRetries ?? params.maxRetries);
const allowManaged = !!params.allowManaged;
if (!name) return { error: '名称不能为空' };
@@ -2703,7 +2730,7 @@ function updateProviderInConfig(params = {}) {
}
try {
- cmdUpdate(name, url || undefined, key, true, { allowManaged, useTransform });
+ cmdUpdate(name, url || undefined, key, true, { allowManaged, useTransform, ...(hasOpenaiBridgeMaxRetries ? { openaiBridgeMaxRetries } : {}) });
return { success: true };
} catch (e) {
return { error: e.message || '更新失败' };
@@ -9846,6 +9873,8 @@ function cmdDelete(name, silent = false) {
function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
const allowManaged = !!(options && options.allowManaged);
const forceUseTransform = !!(options && options.useTransform);
+ const hasOpenaiBridgeMaxRetries = options && Object.prototype.hasOwnProperty.call(options, 'openaiBridgeMaxRetries');
+ const openaiBridgeMaxRetries = normalizeOpenaiBridgeMaxRetries(options && options.openaiBridgeMaxRetries);
const normalizedBaseUrl = baseUrl === undefined ? undefined : normalizeBaseUrl(baseUrl);
if (!name) {
if (!silent) console.error('错误: 提供商名称必填');
@@ -10005,6 +10034,32 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
return next;
};
+ const replaceTomlNumberField = (block, fieldName, rawValue) => {
+ const numberValue = String(Math.floor(Number(rawValue)));
+ const escapedFieldName = escapeRegex(fieldName);
+ const multilineRanges = collectTomlMultilineStringRanges(block);
+ const withCommentRegex = new RegExp(`^(\\s*${escapedFieldName}\\s*=\\s*)([-+]?\\d+(?:\\.\\d+)?)(\\s+#.*)?$`, 'mg');
+ let replaced = false;
+ let next = block.replace(withCommentRegex, (full, prefix, _value, suffix = '', offset) => {
+ if (replaced || isIndexInRanges(offset, multilineRanges)) {
+ return full;
+ }
+ replaced = true;
+ return `${prefix}${numberValue}${suffix}`;
+ });
+ if (!replaced) {
+ const keyIndentMatch = block.match(/^(\s*)[A-Za-z0-9_.-]+\s*=/m);
+ const indent = keyIndentMatch ? keyIndentMatch[1] : '';
+ const lineEnding = block.includes('\r\n') ? '\r\n' : '\n';
+ const tailMatch = block.match(/(\s*)$/);
+ const tail = tailMatch ? tailMatch[1] : '';
+ const body = block.slice(0, block.length - tail.length);
+ const separator = body.endsWith('\n') || body.endsWith('\r') ? '' : lineEnding;
+ next = `${body}${separator}${indent}${fieldName} = ${numberValue}${tail}`;
+ }
+ return next;
+ };
+
const replaceTomlBooleanField = (block, fieldName, rawValue) => {
const boolValue = rawValue ? 'true' : 'false';
const escapedFieldName = escapeRegex(fieldName);
@@ -10061,7 +10116,9 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
: existingApiKey;
if (upstreamBaseUrl) {
- const saveRes = upsertOpenaiBridgeProvider(OPENAI_BRIDGE_SETTINGS_FILE, name, upstreamBaseUrl, upstreamApiKey);
+ const saveRes = upsertOpenaiBridgeProvider(OPENAI_BRIDGE_SETTINGS_FILE, name, upstreamBaseUrl, upstreamApiKey, undefined, {
+ maxRetries: hasOpenaiBridgeMaxRetries ? openaiBridgeMaxRetries : resolveProviderOpenaiBridgeMaxRetries(providerConfig)
+ });
if (saveRes && saveRes.error) {
throw new Error(String(saveRes.error));
}
@@ -10077,6 +10134,9 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
updatedBlock = replaceTomlBooleanField(updatedBlock, 'requires_openai_auth', true);
updatedBlock = replaceTomlStringField(updatedBlock, 'preferred_auth_method', 'codexmate');
updatedBlock = replaceTomlStringField(updatedBlock, 'codexmate_bridge', 'openai');
+ if (hasOpenaiBridgeMaxRetries) {
+ updatedBlock = replaceTomlNumberField(updatedBlock, 'codexmate_bridge_max_retries', openaiBridgeMaxRetries);
+ }
} else {
if (normalizedBaseUrl) {
updatedBlock = replaceTomlStringField(updatedBlock, 'base_url', normalizedBaseUrl);
@@ -12819,7 +12879,10 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
break;
}
// 不返回 apiKey(敏感信息),仅返回用户填过的上游 URL
- result = { baseUrl: upstream.baseUrl, hasApiKey: !!(upstream.apiKey) };
+ const config = readConfig();
+ const provider = config.model_providers && config.model_providers[name];
+ const providerMaxRetries = resolveProviderOpenaiBridgeMaxRetries(provider);
+ result = { baseUrl: upstream.baseUrl, hasApiKey: !!(upstream.apiKey), maxRetries: providerMaxRetries };
break;
}
case 'list-sessions':
@@ -15059,11 +15122,13 @@ function buildMcpProviderListPayload() {
upstreamUrl = upstream.baseUrl.trim();
}
}
+ const openaiBridgeMaxRetries = resolveProviderOpenaiBridgeMaxRetries(p);
return {
name,
url: p.base_url || '',
upstreamUrl,
codexmate_bridge: bridge,
+ openaiBridgeMaxRetries,
key: maskKey(p.preferred_auth_method || ''),
hasKey: !!(p.preferred_auth_method && p.preferred_auth_method.trim()),
models: Array.isArray(p.models)
diff --git a/cli/openai-bridge-retry.js b/cli/openai-bridge-retry.js
new file mode 100644
index 00000000..e2604b8c
--- /dev/null
+++ b/cli/openai-bridge-retry.js
@@ -0,0 +1,62 @@
+const DEFAULT_BRIDGE_MAX_RETRIES = 2;
+const MIN_BRIDGE_MAX_RETRIES = 2;
+const MAX_BRIDGE_MAX_RETRIES = 10;
+const BASE_TRANSIENT_RETRY_DELAY_MS = 200;
+const MAX_TRANSIENT_RETRY_DELAY_MS = 5000;
+
+function normalizeBridgeMaxRetries(value, fallback = DEFAULT_BRIDGE_MAX_RETRIES) {
+ const raw = Number(value);
+ const fallbackRaw = Number(fallback);
+ const base = Number.isFinite(raw) ? raw : (Number.isFinite(fallbackRaw) ? fallbackRaw : DEFAULT_BRIDGE_MAX_RETRIES);
+ return Math.min(MAX_BRIDGE_MAX_RETRIES, Math.max(MIN_BRIDGE_MAX_RETRIES, Math.floor(base)));
+}
+
+function isTransientNetworkError(error) {
+ const text = String(error || '').trim();
+ if (!text) return false;
+ if (/socket hang up/i.test(text)) return true;
+ if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
+ if (/EAI_AGAIN/i.test(text)) return true;
+ if (/UND_ERR_SOCKET/i.test(text)) return true;
+ if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
+ return false;
+}
+
+function getTransientRetryDelayMs(attempt) {
+ const index = Math.max(0, Number(attempt) - 1);
+ return Math.min(MAX_TRANSIENT_RETRY_DELAY_MS, BASE_TRANSIENT_RETRY_DELAY_MS * Math.pow(3, index));
+}
+
+async function retryTransientRequest(executor, options = {}) {
+ const maxRetries = normalizeBridgeMaxRetries(options && options.maxRetries);
+ let lastResult = null;
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
+ if (attempt > 0) {
+ const delay = getTransientRetryDelayMs(attempt);
+ // eslint-disable-next-line no-await-in-loop
+ await new Promise((r) => {
+ const t = setTimeout(r, delay);
+ if (typeof t.unref === 'function') t.unref();
+ });
+ }
+ // eslint-disable-next-line no-await-in-loop
+ const result = await executor(attempt);
+ lastResult = result;
+ if (!result) return result;
+ if (result.ok) return result;
+ if (result.retry) return result;
+ if (result.status && result.status > 0) return result;
+ if (!isTransientNetworkError(result.error)) return result;
+ }
+ return lastResult;
+}
+
+module.exports = {
+ DEFAULT_BRIDGE_MAX_RETRIES,
+ MIN_BRIDGE_MAX_RETRIES,
+ MAX_BRIDGE_MAX_RETRIES,
+ normalizeBridgeMaxRetries,
+ isTransientNetworkError,
+ getTransientRetryDelayMs,
+ retryTransientRequest
+};
diff --git a/cli/openai-bridge-runtime.js b/cli/openai-bridge-runtime.js
new file mode 100644
index 00000000..352a2dbc
--- /dev/null
+++ b/cli/openai-bridge-runtime.js
@@ -0,0 +1,1819 @@
+const http = require('http');
+const https = require('https');
+const crypto = require('crypto');
+const { StringDecoder } = require('string_decoder');
+const { normalizeBridgeMaxRetries, isTransientNetworkError, retryTransientRequest } = require('./openai-bridge-retry');
+
+const STREAM_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
+const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
+
+function parseJsonOrError(text) {
+ if (typeof text !== 'string' || !text.trim()) {
+ return { value: null, error: 'empty body' };
+ }
+ try {
+ return { value: JSON.parse(text), error: '' };
+ } catch (e) {
+ return { value: null, error: e && e.message ? e.message : 'invalid json' };
+ }
+}
+
+function extractChatCompletionResult(payload) {
+ if (!payload || typeof payload !== 'object') return { text: '', toolCalls: [] };
+ const choice = Array.isArray(payload.choices) ? payload.choices[0] : null;
+ const message = choice && typeof choice === 'object' ? choice.message : null;
+ const toolCalls = message && typeof message === 'object' && Array.isArray(message.tool_calls)
+ ? message.tool_calls
+ : [];
+ const content = message && typeof message === 'object' ? message.content : '';
+ let text = '';
+ if (typeof content === 'string') {
+ text = content;
+ } else if (Array.isArray(content)) {
+ text = content
+ .map((item) => {
+ if (!item) return '';
+ if (typeof item === 'string') return item;
+ if (typeof item === 'object') {
+ if (typeof item.text === 'string') return item.text;
+ if (typeof item.content === 'string') return item.content;
+ }
+ return '';
+ })
+ .filter(Boolean)
+ .join('');
+ }
+ return { text, toolCalls };
+}
+
+function stringifyJsonValue(value, fallback = '') {
+ if (typeof value === 'string') return value;
+ if (value == null) return fallback;
+ try {
+ return JSON.stringify(value);
+ } catch (_) {
+ return fallback;
+ }
+}
+
+function parseJsonValueOrNull(value) {
+ if (typeof value !== 'string') return null;
+ const text = value.trim();
+ if (!text) return null;
+ try {
+ return JSON.parse(text);
+ } catch (_) {
+ return null;
+ }
+}
+
+function isRecord(value) {
+ return !!value && typeof value === 'object' && !Array.isArray(value);
+}
+
+function asTrimmedString(value) {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+function cloneJsonValue(value) {
+ if (Array.isArray(value)) return value.map((item) => cloneJsonValue(item));
+ if (isRecord(value)) {
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]));
+ }
+ return value;
+}
+
+function normalizeResponsesToolOutput(value) {
+ if (typeof value === 'string') return value || '(empty)';
+ if (value == null) return '(empty)';
+ return stringifyJsonValue(value, '(empty)') || '(empty)';
+}
+
+function chatContentToPlainText(content) {
+ if (typeof content === 'string') return content;
+ if (!Array.isArray(content)) return '';
+ return content
+ .map((item) => {
+ if (typeof item === 'string') return item;
+ if (!isRecord(item)) return '';
+ if (typeof item.text === 'string') return item.text;
+ if (typeof item.content === 'string') return item.content;
+ return '';
+ })
+ .join('');
+}
+
+function wrapReasoningContentForChat(content) {
+ const text = chatContentToPlainText(content).trim();
+ return text ? `${text}` : '';
+}
+
+function normalizeOpenAiToolArguments(value) {
+ if (typeof value === 'string') return value;
+ if (value == null) return '{}';
+ return stringifyJsonValue(value, '{}');
+}
+
+function normalizeInputFileBlock(item) {
+ if (!isRecord(item)) return null;
+ const file = isRecord(item.file) ? item.file : item;
+ const out = {};
+ const fileId = asTrimmedString(file.file_id || file.id);
+ const filename = asTrimmedString(file.filename || file.name);
+ const fileData = asTrimmedString(file.file_data || file.data);
+ const mimeType = asTrimmedString(file.mime_type || file.media_type);
+ if (fileId) out.file_id = fileId;
+ if (filename) out.filename = filename;
+ if (fileData) out.file_data = fileData;
+ if (mimeType) out.mime_type = mimeType;
+ return Object.keys(out).length > 0 ? out : null;
+}
+
+function normalizeResponsesContentBlockForChat(item) {
+ if (typeof item === 'string') return item.trim() ? item : null;
+ if (!isRecord(item)) return null;
+
+ const type = asTrimmedString(item.type).toLowerCase();
+ if (!type) {
+ const text = asTrimmedString(item.text || item.content || item.output_text);
+ return text ? { type: 'text', text } : null;
+ }
+
+ if (type === 'input_text' || type === 'output_text' || type === 'text' || type === 'summary_text' || type === 'reasoning_text') {
+ const text = typeof item.text === 'string' ? item.text : asTrimmedString(item.content || item.output_text);
+ return text ? { type: 'text', text } : null;
+ }
+
+ if (type === 'refusal' && typeof item.refusal === 'string') {
+ return item.refusal ? { type: 'text', text: item.refusal } : null;
+ }
+
+ if (type === 'input_image') {
+ const raw = item.image_url != null ? item.image_url : (item.url != null ? item.url : item.imageUrl);
+ if (raw === undefined) return null;
+ return {
+ type: 'image_url',
+ image_url: typeof raw === 'string' ? { url: raw } : cloneJsonValue(raw)
+ };
+ }
+
+ if (type === 'image_url' && item.image_url !== undefined) {
+ return { type: 'image_url', image_url: item.image_url };
+ }
+
+ if (type === 'input_audio') {
+ if (item.input_audio !== undefined) return { type: 'input_audio', input_audio: item.input_audio };
+ if (item.data !== undefined || item.format !== undefined) {
+ return { type: 'input_audio', input_audio: { data: item.data, format: item.format } };
+ }
+ return null;
+ }
+
+ if (type === 'input_file' || type === 'file') {
+ const file = normalizeInputFileBlock(item);
+ return file ? { type: 'file', file } : null;
+ }
+
+ if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
+ const text = asTrimmedString(item.text || item.content);
+ return text ? { type: 'text', text } : null;
+ }
+
+ const text = asTrimmedString(item.text || item.content);
+ return text ? { type: 'text', text } : cloneJsonValue(item);
+}
+
+function toOpenAiMessageContent(content) {
+ if (typeof content === 'string') return content;
+ if (!Array.isArray(content)) {
+ if (isRecord(content)) {
+ const single = normalizeResponsesContentBlockForChat(content);
+ if (!single) return '';
+ return typeof single === 'string' ? single : [single];
+ }
+ return '';
+ }
+
+ const blocks = content
+ .map((item) => normalizeResponsesContentBlockForChat(item))
+ .filter((item) => !!item);
+
+ if (blocks.length === 0) return '';
+ if (blocks.length === 1 && typeof blocks[0] === 'string') return blocks[0];
+ return blocks;
+}
+
+const RESPONSES_TOOL_CALL_INPUT_TYPES = new Set(['function_call', 'custom_tool_call', 'mcp_tool_call', 'local_shell_call']);
+const RESPONSES_TOOL_CALL_OUTPUT_TYPES = new Set(['function_call_output', 'custom_tool_call_output', 'mcp_tool_call_output', 'tool_search_output', 'local_shell_call_output']);
+
+function stripOrphanedResponsesToolOutputs(input) {
+ if (!Array.isArray(input)) return input;
+ const seenToolCallIds = new Set();
+ const sanitized = [];
+ for (const item of input) {
+ if (!isRecord(item)) {
+ sanitized.push(item);
+ continue;
+ }
+ const type = asTrimmedString(item.type).toLowerCase();
+ if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(type)) {
+ const callId = asTrimmedString(item.call_id || item.id);
+ if (callId) seenToolCallIds.add(callId);
+ sanitized.push(item);
+ continue;
+ }
+ if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(type)) {
+ const callId = asTrimmedString(item.call_id || item.id);
+ if (!callId || !seenToolCallIds.has(callId)) continue;
+ sanitized.push(item);
+ continue;
+ }
+ sanitized.push(item);
+ }
+ return sanitized;
+}
+
+function normalizeFreeformToolArguments(value) {
+ if (typeof value === 'string') return stringifyJsonValue({ input: value }, '{"input":""}');
+ if (value == null) return '{"input":""}';
+ if (isRecord(value) && Object.prototype.hasOwnProperty.call(value, 'input')) {
+ return stringifyJsonValue(value, '{"input":""}');
+ }
+ return stringifyJsonValue({ input: normalizeResponsesToolOutput(value) }, '{"input":""}');
+}
+
+function toOpenAiToolCall(item, fallbackIndex) {
+ if (!isRecord(item)) return null;
+ const callId = asTrimmedString(item.call_id || item.id) || `call_${crypto.randomBytes(8).toString('hex')}_${fallbackIndex}`;
+ const type = asTrimmedString(item.type).toLowerCase();
+ const name = asTrimmedString(item.name)
+ || asTrimmedString(item.server_label)
+ || (type === 'local_shell_call' ? 'local_shell' : '');
+ if (!name) return null;
+ const rawArguments = item.arguments != null
+ ? item.arguments
+ : (item.input != null ? item.input : (item.action != null ? item.action : item.command));
+ const args = (type === 'custom_tool_call' && item.arguments == null)
+ ? normalizeFreeformToolArguments(rawArguments)
+ : normalizeOpenAiToolArguments(rawArguments);
+ return {
+ id: callId,
+ type: 'function',
+ function: {
+ name,
+ arguments: args
+ }
+ };
+}
+
+function hasOpenAiMessageContent(content) {
+ return typeof content === 'string'
+ ? content.trim().length > 0
+ : Array.isArray(content) && content.length > 0;
+}
+
+function normalizeResponsesInputToChatMessages(input) {
+ // Keep the OpenAI bridge in lockstep with the builtin proxy's Responses → Chat shim.
+ // Codex long-running tasks append richer Responses history (custom/local_shell/MCP calls)
+ // back into `input`; dropping those items makes the next model turn lose tool state and stop early.
+ const messages = [];
+ const normalizedInput = stripOrphanedResponsesToolOutputs(input);
+ let functionCallIndex = 0;
+ let pendingToolCalls = [];
+ const emittedToolCallIds = new Set();
+
+ const flushPendingToolCalls = () => {
+ if (pendingToolCalls.length <= 0) return;
+ for (const toolCall of pendingToolCalls) {
+ const callId = asTrimmedString(toolCall.id);
+ if (callId) emittedToolCallIds.add(callId);
+ }
+ messages.push({
+ role: 'assistant',
+ content: null,
+ tool_calls: pendingToolCalls
+ });
+ pendingToolCalls = [];
+ };
+
+ const pushToolOutputMessage = (callIdRaw, outputRaw) => {
+ const toolCallId = asTrimmedString(callIdRaw);
+ if (!toolCallId) return;
+ messages.push({
+ role: 'tool',
+ tool_call_id: toolCallId,
+ content: normalizeResponsesToolOutput(outputRaw)
+ });
+ };
+
+ const processInputItem = (item) => {
+ if (typeof item === 'string') {
+ flushPendingToolCalls();
+ const text = item.trim();
+ if (text) messages.push({ role: 'user', content: text });
+ return;
+ }
+ if (!isRecord(item)) return;
+
+ const itemType = asTrimmedString(item.type).toLowerCase();
+ if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(itemType)) {
+ const toolCall = toOpenAiToolCall(item, functionCallIndex);
+ functionCallIndex += 1;
+ if (toolCall) pendingToolCalls.push(toolCall);
+ return;
+ }
+
+ if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(itemType)) {
+ flushPendingToolCalls();
+ const toolCallId = asTrimmedString(item.call_id || item.id);
+ if (!toolCallId || !emittedToolCallIds.has(toolCallId)) return;
+ pushToolOutputMessage(toolCallId, item.output != null ? item.output : item.content);
+ return;
+ }
+
+ if (itemType === 'reasoning') {
+ flushPendingToolCalls();
+ const reasoningContent = wrapReasoningContentForChat(toOpenAiMessageContent(item.summary != null ? item.summary : (item.content != null ? item.content : item)));
+ const reasoningSignature = asTrimmedString(item.encrypted_content || item.reasoning_signature);
+ if (!hasOpenAiMessageContent(reasoningContent) && !reasoningSignature) return;
+ const message = { role: 'assistant', content: reasoningContent };
+ if (reasoningSignature) message.reasoning_signature = reasoningSignature;
+ messages.push(message);
+ return;
+ }
+
+ flushPendingToolCalls();
+ const role = asTrimmedString(item.role).toLowerCase() || 'user';
+ const normalizedRole = role === 'developer' ? 'system' : role;
+ const content = toOpenAiMessageContent(item.content != null ? item.content : (item.input != null ? item.input : item));
+
+ if (normalizedRole === 'tool') {
+ const toolCallId = asTrimmedString(item.tool_call_id || item.call_id || item.id);
+ if (!toolCallId || !emittedToolCallIds.has(toolCallId)) return;
+ pushToolOutputMessage(toolCallId, item.content);
+ return;
+ }
+
+ if (!hasOpenAiMessageContent(content)) return;
+ const message = { role: normalizedRole, content };
+ const phase = asTrimmedString(item.phase);
+ if (phase) message.phase = phase;
+ messages.push(message);
+ };
+
+ if (typeof normalizedInput === 'string') {
+ const text = normalizedInput.trim();
+ if (text) messages.push({ role: 'user', content: text });
+ } else if (Array.isArray(normalizedInput)) {
+ for (const item of normalizedInput) processInputItem(item);
+ } else if (isRecord(normalizedInput)) {
+ processInputItem(normalizedInput);
+ }
+ flushPendingToolCalls();
+ return messages;
+}
+
+function normalizeFunctionToolForChat(tool) {
+ if (!isRecord(tool)) return null;
+ const sourceFn = isRecord(tool.function) ? tool.function : tool;
+ const name = asTrimmedString(sourceFn.name) || asTrimmedString(tool.name);
+ if (!name) return null;
+ const fn = { name };
+ const description = asTrimmedString(sourceFn.description) || asTrimmedString(tool.description);
+ if (description) fn.description = description;
+ if (sourceFn.parameters !== undefined) {
+ fn.parameters = cloneJsonValue(sourceFn.parameters);
+ } else if (tool.parameters !== undefined) {
+ fn.parameters = cloneJsonValue(tool.parameters);
+ }
+ if (typeof sourceFn.strict === 'boolean') {
+ fn.strict = sourceFn.strict;
+ } else if (typeof tool.strict === 'boolean') {
+ fn.strict = tool.strict;
+ }
+ return { type: 'function', function: fn };
+}
+
+function buildLocalShellToolForChat(tool) {
+ return {
+ type: 'function',
+ function: {
+ name: asTrimmedString(tool && tool.name) || 'local_shell',
+ description: asTrimmedString(tool && tool.description) || 'Run a local shell command and return its output.',
+ parameters: {
+ type: 'object',
+ properties: {
+ cmd: { type: 'string', description: 'Shell command to execute.' },
+ yield_time_ms: { type: 'number', description: 'Milliseconds to wait before yielding partial output.' },
+ max_output_tokens: { type: 'number', description: 'Maximum output tokens to return.' }
+ },
+ required: ['cmd'],
+ additionalProperties: true
+ }
+ }
+ };
+}
+
+function buildFreeformToolForChat(tool, fallbackName = 'custom_tool') {
+ return {
+ type: 'function',
+ function: {
+ name: asTrimmedString(tool && tool.name) || fallbackName,
+ description: asTrimmedString(tool && tool.description) || 'Pass raw freeform input to the local tool.',
+ parameters: {
+ type: 'object',
+ properties: {
+ input: { type: 'string', description: 'Raw tool input.' }
+ },
+ required: ['input'],
+ additionalProperties: false
+ }
+ }
+ };
+}
+
+const MAX_RESPONSES_TOOL_NAMESPACE_DEPTH = 5;
+
+function rememberResponsesToolType(tool, target, depth = 0) {
+ if (!isRecord(tool) || !target || depth > MAX_RESPONSES_TOOL_NAMESPACE_DEPTH) return;
+ const type = asTrimmedString(tool.type).toLowerCase();
+ if (type === 'namespace' && Array.isArray(tool.tools)) {
+ for (const inner of tool.tools) rememberResponsesToolType(inner, target, depth + 1);
+ return;
+ }
+ const sourceFn = isRecord(tool.function) ? tool.function : tool;
+ const name = asTrimmedString(sourceFn.name) || asTrimmedString(tool.name);
+ if (!name) return;
+ if (type === 'local_shell') {
+ target[name] = 'local_shell_call';
+ return;
+ }
+ if (type === 'custom' || type === 'custom_tool' || (!type && name === 'apply_patch')) {
+ target[name] = 'custom_tool_call';
+ return;
+ }
+ if (type === 'function') {
+ target[name] = 'function_call';
+ }
+}
+
+function collectResponsesToolTypesByName(tools) {
+ const result = {};
+ if (!Array.isArray(tools)) return result;
+ for (const tool of tools) rememberResponsesToolType(tool, result);
+ return result;
+}
+
+function extractFreeformInputFromChatArguments(argumentsText) {
+ if (typeof argumentsText !== 'string') return '';
+ const parsed = parseJsonValueOrNull(argumentsText);
+ if (isRecord(parsed) && Object.prototype.hasOwnProperty.call(parsed, 'input')) {
+ return typeof parsed.input === 'string' ? parsed.input : normalizeResponsesToolOutput(parsed.input);
+ }
+ return argumentsText;
+}
+
+function extractLocalShellActionFromChatArguments(argumentsText) {
+ const parsed = parseJsonValueOrNull(argumentsText);
+ if (isRecord(parsed)) return cloneJsonValue(parsed);
+ return { cmd: typeof argumentsText === 'string' ? argumentsText : '' };
+}
+
+function buildResponsesToolCallItemFromChatToolCall(toolCall, toolTypesByName = {}) {
+ if (!isRecord(toolCall)) return null;
+ const fn = isRecord(toolCall.function) ? toolCall.function : {};
+ const name = asTrimmedString(fn.name);
+ if (!name) return null;
+ const callId = asTrimmedString(toolCall.id) || `call_${crypto.randomBytes(8).toString('hex')}`;
+ const argumentsText = typeof fn.arguments === 'string' ? fn.arguments : '';
+ const responseType = toolTypesByName && toolTypesByName[name] ? toolTypesByName[name] : 'function_call';
+
+ if (responseType === 'custom_tool_call') {
+ return {
+ type: 'custom_tool_call',
+ call_id: callId,
+ name,
+ input: extractFreeformInputFromChatArguments(argumentsText)
+ };
+ }
+ if (responseType === 'local_shell_call') {
+ return {
+ type: 'local_shell_call',
+ call_id: callId,
+ name,
+ action: extractLocalShellActionFromChatArguments(argumentsText)
+ };
+ }
+ return {
+ type: 'function_call',
+ call_id: callId,
+ name,
+ arguments: argumentsText
+ };
+}
+
+function normalizeSingleResponsesToolToChatTools(tool, depth = 0) {
+ if (!isRecord(tool) || depth > MAX_RESPONSES_TOOL_NAMESPACE_DEPTH) return [];
+ const type = asTrimmedString(tool.type).toLowerCase();
+ if (type === 'namespace' && Array.isArray(tool.tools)) {
+ return tool.tools.flatMap((inner) => normalizeSingleResponsesToolToChatTools(inner, depth + 1));
+ }
+ if (type === 'function') {
+ const converted = normalizeFunctionToolForChat(tool);
+ return converted ? [converted] : [];
+ }
+ if (type === 'local_shell') {
+ return [buildLocalShellToolForChat(tool)];
+ }
+ const name = asTrimmedString(tool.name);
+ if (type === 'custom' || type === 'custom_tool' || (!type && name === 'apply_patch')) {
+ return [buildFreeformToolForChat(tool, name || 'custom_tool')];
+ }
+ return [];
+}
+
+function normalizeResponsesToolsToChatTools(tools) {
+ if (!Array.isArray(tools)) return tools;
+ return tools.flatMap((tool) => normalizeSingleResponsesToolToChatTools(tool));
+}
+
+function normalizeResponsesToolChoiceToChatToolChoice(toolChoice) {
+ if (toolChoice === undefined) return undefined;
+ if (typeof toolChoice === 'string') return toolChoice;
+ if (!isRecord(toolChoice)) return toolChoice;
+
+ const type = asTrimmedString(toolChoice.type).toLowerCase();
+ if (type === 'tool' || type === 'function' || type === 'custom' || type === 'custom_tool' || type === 'local_shell') {
+ if (isRecord(toolChoice.function) && asTrimmedString(toolChoice.function.name)) return cloneJsonValue(toolChoice);
+ const name = asTrimmedString(toolChoice.name) || asTrimmedString(toolChoice.server_label);
+ if (!name) return 'required';
+ return { type: 'function', function: { name } };
+ }
+ if (type === 'auto' || type === 'none' || type === 'required') return type;
+ return 'auto';
+}
+
+function getChatToolChoiceName(toolChoice) {
+ if (!isRecord(toolChoice)) return '';
+ if (isRecord(toolChoice.function)) return asTrimmedString(toolChoice.function.name);
+ return '';
+}
+
+function pruneInvalidChatToolChoice(chatBody) {
+ if (!isRecord(chatBody) || !Array.isArray(chatBody.tools)) return;
+ if (chatBody.tools.length === 0) {
+ delete chatBody.tools;
+ delete chatBody.tool_choice;
+ return;
+ }
+ const chosenName = getChatToolChoiceName(chatBody.tool_choice);
+ if (!chosenName) return;
+ const toolNames = new Set(chatBody.tools
+ .map((tool) => isRecord(tool) && isRecord(tool.function) ? asTrimmedString(tool.function.name) : '')
+ .filter(Boolean));
+ if (!toolNames.has(chosenName)) {
+ delete chatBody.tool_choice;
+ }
+}
+
+function normalizeResponsesToolsForResponsesApi(tools) {
+ if (!Array.isArray(tools)) return tools;
+ return tools
+ .map((tool) => {
+ const converted = normalizeFunctionToolForChat(tool);
+ if (!converted || !converted.function) return null;
+ const out = {
+ type: 'function',
+ name: converted.function.name
+ };
+ if (converted.function.description !== undefined) out.description = converted.function.description;
+ if (converted.function.parameters !== undefined) out.parameters = converted.function.parameters;
+ if (converted.function.strict !== undefined) out.strict = converted.function.strict;
+ return out;
+ })
+ .filter(Boolean);
+}
+
+function mergeLeadingSystemMessages(messages, leadingInstructions) {
+ const segments = [];
+ const seen = new Set();
+ const pushSegment = (text) => {
+ const trimmed = typeof text === 'string' ? text.trim() : '';
+ if (!trimmed || seen.has(trimmed)) return;
+ seen.add(trimmed);
+ segments.push(trimmed);
+ };
+ if (typeof leadingInstructions === 'string') {
+ pushSegment(leadingInstructions);
+ }
+ const rest = [];
+ for (const msg of messages) {
+ if (msg && msg.role === 'system') {
+ const content = msg.content;
+ if (typeof content === 'string') {
+ pushSegment(content);
+ } else if (Array.isArray(content)) {
+ for (const part of content) {
+ if (part && typeof part === 'object' && typeof part.text === 'string') {
+ pushSegment(part.text);
+ }
+ }
+ }
+ continue;
+ }
+ rest.push(msg);
+ }
+ const out = [];
+ if (segments.length) {
+ out.push({ role: 'system', content: segments.join('\n\n---\n\n') });
+ }
+ for (const msg of rest) out.push(msg);
+ return out;
+}
+
+function messageContentAsText(content) {
+ if (typeof content === 'string') return content;
+ if (!Array.isArray(content)) return '';
+ return content
+ .map((item) => {
+ if (typeof item === 'string') return item;
+ if (!isRecord(item)) return '';
+ if (typeof item.text === 'string') return item.text;
+ if (typeof item.content === 'string') return item.content;
+ return '';
+ })
+ .filter(Boolean)
+ .join('\n');
+}
+
+function hasRunningCodexExecSession(messages) {
+ if (!Array.isArray(messages)) return false;
+ return messages.some((message) => {
+ if (!isRecord(message) || message.role !== 'tool') return false;
+ return /Process running with session ID\s+\d+/i.test(messageContentAsText(message.content));
+ });
+}
+
+function appendChatFallbackRuntimeInstructions(baseInstructions, rawMessages) {
+ const segments = [];
+ const base = typeof baseInstructions === 'string' ? baseInstructions.trim() : '';
+ if (base) segments.push(base);
+ if (hasRunningCodexExecSession(rawMessages)) {
+ segments.push('Codex tool output indicates a command is still running ("Process running with session ID ..."). You must call write_stdin with that numeric session_id and empty chars to poll/wait for completion before giving a final answer. Do not merely say that you are waiting.');
+ }
+ return segments.join('\n\n');
+}
+
+function convertResponsesRequestToChatCompletions(payload) {
+ const body = payload && typeof payload === 'object' ? payload : {};
+ const model = typeof body.model === 'string' ? body.model.trim() : '';
+ if (!model) {
+ return { error: 'responses 请求缺少 model' };
+ }
+
+ const rawMessages = normalizeResponsesInputToChatMessages(body.input);
+ const leadingInstructions = appendChatFallbackRuntimeInstructions(body.instructions, rawMessages);
+ // codex 同时下发 body.instructions(内置 prompt)与 input 内 developer/system 消息(AGENTS.md)。
+ // 合流为一条领头 system,避免某些上游"只认第一条 system"导致 AGENTS.md 失效。
+ const messages = mergeLeadingSystemMessages(rawMessages, leadingInstructions);
+ if (!messages.length) {
+ // codex sometimes sends empty input for probes; tolerate.
+ messages.push({ role: 'user', content: '' });
+ }
+
+ const maxOutputTokens = Number.parseInt(String(body.max_output_tokens), 10);
+ const stream = body.stream === true;
+
+ const chat = {
+ model,
+ messages,
+ stream: false,
+ temperature: Number.isFinite(body.temperature) ? Number(body.temperature) : undefined,
+ top_p: Number.isFinite(body.top_p) ? Number(body.top_p) : undefined,
+ max_tokens: Number.isFinite(maxOutputTokens) && maxOutputTokens > 0 ? maxOutputTokens : undefined
+ };
+ if (isRecord(body.reasoning)) {
+ const effort = asTrimmedString(body.reasoning.effort);
+ if (effort) chat.reasoning_effort = effort;
+ } else if (asTrimmedString(body.reasoning_effort)) {
+ chat.reasoning_effort = asTrimmedString(body.reasoning_effort);
+ }
+ if (Array.isArray(body.stop) && body.stop.length) {
+ chat.stop = body.stop.filter((item) => typeof item === 'string' && item.trim());
+ }
+ if (Array.isArray(body.tools) && body.tools.length) {
+ chat.tools = normalizeResponsesToolsToChatTools(body.tools);
+ }
+ if (body.tool_choice !== undefined) {
+ chat.tool_choice = normalizeResponsesToolChoiceToChatToolChoice(body.tool_choice);
+ }
+ if (body.response_format !== undefined) {
+ chat.response_format = body.response_format;
+ }
+ if (body.metadata !== undefined) {
+ chat.metadata = body.metadata;
+ }
+
+ pruneInvalidChatToolChoice(chat);
+
+ // Remove undefined keys
+ Object.keys(chat).forEach((key) => chat[key] === undefined && delete chat[key]);
+
+ return { chat, streamRequested: stream, toolTypesByName: collectResponsesToolTypesByName(body.tools) };
+}
+
+function buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamPayload, options = {}) {
+ const responseId = `resp_${crypto.randomBytes(10).toString('hex')}`;
+ const usage = upstreamPayload && upstreamPayload.usage && typeof upstreamPayload.usage === 'object'
+ ? upstreamPayload.usage
+ : null;
+ const createdAt = Math.floor(Date.now() / 1000);
+ const output = [];
+ const trimmedText = typeof text === 'string' ? text : '';
+ if (trimmedText) {
+ output.push({
+ id: `msg_${crypto.randomBytes(8).toString('hex')}`,
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'output_text', text: trimmedText }]
+ });
+ }
+
+ // Convert chat.completions tool_calls back into the original Responses item type.
+ // Treating every call as `function_call` makes Codex built-ins (custom/local_shell)
+ // degrade into ordinary chat text instead of executable agent steps.
+ if (Array.isArray(toolCalls)) {
+ for (const call of toolCalls) {
+ const item = buildResponsesToolCallItemFromChatToolCall(call, options.toolTypesByName || {});
+ if (item) output.push(item);
+ }
+ }
+
+ const choice = upstreamPayload && typeof upstreamPayload === 'object' && Array.isArray(upstreamPayload.choices)
+ ? upstreamPayload.choices[0]
+ : null;
+ const finishReason = choice && typeof choice.finish_reason === 'string' ? choice.finish_reason : '';
+ const statusPatch = finishReason === 'length'
+ ? { status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } }
+ : (finishReason === 'content_filter'
+ ? { status: 'incomplete', incomplete_details: { reason: 'content_filter' } }
+ : { status: 'completed' });
+
+ const payload = {
+ id: responseId,
+ object: 'response',
+ model,
+ created_at: createdAt,
+ ...statusPatch,
+ output,
+ output_text: trimmedText
+ };
+
+ if (usage) {
+ // Map chat.completions usage -> responses usage shape when possible.
+ const promptTokens = Number.isFinite(usage.prompt_tokens) ? Number(usage.prompt_tokens) : null;
+ const completionTokens = Number.isFinite(usage.completion_tokens) ? Number(usage.completion_tokens) : null;
+ const totalTokens = Number.isFinite(usage.total_tokens) ? Number(usage.total_tokens) : null;
+ if (promptTokens !== null || completionTokens !== null || totalTokens !== null) {
+ payload.usage = {
+ input_tokens: promptTokens ?? undefined,
+ output_tokens: completionTokens ?? undefined,
+ total_tokens: totalTokens ?? undefined
+ };
+ Object.keys(payload.usage).forEach((key) => payload.usage[key] === undefined && delete payload.usage[key]);
+ } else {
+ payload.usage = usage;
+ }
+ }
+
+ return payload;
+}
+
+function ensureResponseMetadata(response) {
+ const payload = response && typeof response === 'object' ? response : {};
+ if (typeof payload.object !== 'string' || !payload.object.trim()) {
+ payload.object = 'response';
+ }
+ if (typeof payload.created_at !== 'number') {
+ payload.created_at = Math.floor(Date.now() / 1000);
+ }
+ if (typeof payload.status !== 'string' || !payload.status.trim()) {
+ payload.status = 'completed';
+ }
+ if (!Array.isArray(payload.output)) {
+ payload.output = [];
+ }
+ return payload;
+}
+
+function sendResponsesSse(res, responsePayload) {
+ const response = ensureResponseMetadata(responsePayload);
+ const responseId = typeof response.id === 'string' && response.id.trim()
+ ? response.id.trim()
+ : `resp_${crypto.randomBytes(10).toString('hex')}`;
+ const model = typeof response.model === 'string' ? response.model : '';
+
+ let sequence = 0;
+ const nextSeq = () => {
+ sequence += 1;
+ return sequence;
+ };
+
+ writeSse(res, 'response.created', {
+ type: 'response.created',
+ response: {
+ id: responseId,
+ model,
+ created_at: response.created_at
+ }
+ });
+
+ const output = Array.isArray(response.output) ? response.output : [];
+ for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
+ const item = output[outputIndex];
+ if (!item || typeof item !== 'object') continue;
+ const itemType = typeof item.type === 'string' ? item.type : '';
+ const itemId = typeof item.id === 'string' && item.id.trim()
+ ? item.id.trim()
+ : (typeof item.call_id === 'string' && item.call_id.trim() ? item.call_id.trim() : `item_${crypto.randomBytes(8).toString('hex')}`);
+ const wireItem = buildResponsesSseItem(item, itemId);
+
+ // Emit item added so Codex can anchor subsequent deltas by output_index/content_index/item_id.
+ writeSse(res, 'response.output_item.added', {
+ type: 'response.output_item.added',
+ output_index: outputIndex,
+ item: wireItem,
+ sequence_number: nextSeq()
+ });
+
+ if (itemType === 'message') {
+ const content = Array.isArray(item.content) ? item.content : [];
+ for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
+ const block = content[contentIndex];
+ if (!block || typeof block !== 'object') continue;
+ if (block.type !== 'output_text') continue;
+ const text = typeof block.text === 'string' ? block.text : '';
+ emitResponsesTextPartAdded(res, itemId, outputIndex, contentIndex, nextSeq);
+ if (text) {
+ writeSse(res, 'response.output_text.delta', {
+ type: 'response.output_text.delta',
+ item_id: itemId,
+ output_index: outputIndex,
+ content_index: contentIndex,
+ delta: text,
+ sequence_number: nextSeq()
+ });
+ }
+ writeSse(res, 'response.output_text.done', {
+ type: 'response.output_text.done',
+ item_id: itemId,
+ output_index: outputIndex,
+ content_index: contentIndex,
+ text,
+ sequence_number: nextSeq()
+ });
+ emitResponsesTextPartDone(res, itemId, outputIndex, contentIndex, text, nextSeq);
+ }
+ } else if (itemType === 'function_call' || itemType === 'custom_tool_call') {
+ emitResponsesToolArgumentEvents(res, wireItem, outputIndex, nextSeq);
+ } else if (itemType === 'reasoning') {
+ const summary = Array.isArray(item.summary) ? item.summary : [];
+ for (let summaryIndex = 0; summaryIndex < summary.length; summaryIndex += 1) {
+ const block = summary[summaryIndex];
+ const text = block && typeof block.text === 'string' ? block.text : '';
+ if (text) {
+ writeSse(res, 'response.reasoning_summary_text.delta', {
+ type: 'response.reasoning_summary_text.delta',
+ item_id: itemId,
+ output_index: outputIndex,
+ summary_index: summaryIndex,
+ delta: text,
+ sequence_number: nextSeq()
+ });
+ }
+ writeSse(res, 'response.reasoning_summary_text.done', {
+ type: 'response.reasoning_summary_text.done',
+ item_id: itemId,
+ output_index: outputIndex,
+ summary_index: summaryIndex,
+ text,
+ sequence_number: nextSeq()
+ });
+ }
+ }
+
+ // Emit item done for all item types (message/function_call/etc).
+ writeSse(res, 'response.output_item.done', {
+ type: 'response.output_item.done',
+ output_index: outputIndex,
+ item: wireItem,
+ sequence_number: nextSeq()
+ });
+ }
+
+ writeSse(res, 'response.completed', { type: 'response.completed', response });
+ writeSse(res, 'done', '[DONE]');
+ }
+
+function extractResponsesOutputText(payload) {
+ if (!payload || typeof payload !== 'object') return '';
+ const output = Array.isArray(payload.output) ? payload.output : [];
+ for (const item of output) {
+ if (!item || typeof item !== 'object') continue;
+ if (item.type !== 'message') continue;
+ const content = Array.isArray(item.content) ? item.content : [];
+ for (const block of content) {
+ if (!block || typeof block !== 'object') continue;
+ if (block.type !== 'output_text') continue;
+ if (typeof block.text === 'string') return block.text;
+ }
+ }
+ if (typeof payload.output_text === 'string') return payload.output_text;
+ return '';
+}
+
+function normalizeResponsesPayloadForUpstream(payload, stream) {
+ const body = payload && typeof payload === 'object' ? payload : {};
+ const normalized = { ...body, stream };
+ if (Array.isArray(body.tools)) {
+ normalized.tools = normalizeResponsesToolsForResponsesApi(body.tools);
+ }
+ if (isRecord(body.reasoning)) {
+ const include = Array.isArray(body.include) ? body.include.filter((item) => typeof item === 'string') : [];
+ if (!include.includes('reasoning.encrypted_content')) {
+ normalized.include = [...include, 'reasoning.encrypted_content'];
+ }
+ }
+ return normalized;
+}
+
+function toUpstreamNonStreamingResponsesPayload(payload) {
+ return normalizeResponsesPayloadForUpstream(payload, false);
+}
+
+function shouldFallbackFromUpstreamResponses(status, bodyText) {
+ if (!Number.isFinite(status)) return false;
+ // Common "unsupported" status codes for a route.
+ if (status === 404 || status === 405 || status === 501) return true;
+
+ // Some OpenAI-compatible gateways respond with 500 + "not implemented" (e.g. convert_request_failed)
+ // instead of 404/405 for unsupported endpoints. In that case we can safely fallback to chat/completions.
+ const text = String(bodyText || '');
+ if (!text) return false;
+ if (/not implemented/i.test(text)) return true;
+ if (/convert_request_failed/i.test(text)) return true;
+ if (/unknown (endpoint|route)/i.test(text)) return true;
+ if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
+ if (/does not support.*responses/i.test(text)) return true;
+ if (/name['"`]?\s+is a required property/i.test(text) && /tools/i.test(text) && /function/i.test(text)) return true;
+
+ // Best-effort parse for structured error codes.
+ try {
+ const parsed = JSON.parse(text);
+ const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
+ const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
+ if (code === 'convert_request_failed') return true;
+ if (/not implemented/i.test(msg)) return true;
+ if (/unknown (endpoint|route)/i.test(msg)) return true;
+ if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
+ if (/does not support.*responses/i.test(msg)) return true;
+ if (/name['"`]?\s+is a required property/i.test(msg) && /tools/i.test(msg) && /function/i.test(msg)) return true;
+ } catch (_) {}
+
+ return false;
+}
+
+// 仅识别"端点级别不支持"——可缓存,与 per-request 的 tool 格式错误区分。
+function isResponsesEndpointUnsupported(status, bodyText) {
+ if (!Number.isFinite(status)) return false;
+ if (status === 404 || status === 405 || status === 501) return true;
+ const text = String(bodyText || '');
+ if (!text) return false;
+ if (/not implemented/i.test(text)) return true;
+ if (/convert_request_failed/i.test(text)) return true;
+ if (/unknown (endpoint|route)/i.test(text)) return true;
+ if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
+ if (/does not support.*responses/i.test(text)) return true;
+ try {
+ const parsed = JSON.parse(text);
+ const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
+ const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
+ if (code === 'convert_request_failed') return true;
+ if (/not implemented/i.test(msg)) return true;
+ if (/unknown (endpoint|route)/i.test(msg)) return true;
+ if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
+ if (/does not support.*responses/i.test(msg)) return true;
+ } catch (_) {}
+ return false;
+}
+
+function isLoopbackAddress(address) {
+ if (!address) return false;
+ const value = String(address);
+ return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
+}
+
+function writeSse(res, eventName, dataObj) {
+ if (!res || res.writableEnded || res.destroyed) return;
+ if (eventName) {
+ res.write(`event: ${eventName}\n`);
+ }
+ if (dataObj === '[DONE]') {
+ res.write('data: [DONE]\n\n');
+ return;
+ }
+ res.write(`data: ${JSON.stringify(dataObj)}\n\n`);
+}
+
+function buildResponsesSseItem(item, fallbackId) {
+ const source = isRecord(item) ? item : {};
+ const itemId = asTrimmedString(source.id || source.call_id) || fallbackId || `item_${crypto.randomBytes(8).toString('hex')}`;
+ if (source.type === 'message') {
+ return {
+ ...source,
+ id: itemId,
+ role: source.role || 'assistant',
+ content: Array.isArray(source.content) ? source.content : []
+ };
+ }
+ if (source.type === 'reasoning') {
+ return {
+ ...source,
+ id: itemId,
+ summary: Array.isArray(source.summary) ? source.summary : []
+ };
+ }
+ if (source.type === 'function_call') {
+ return {
+ ...source,
+ id: itemId,
+ call_id: asTrimmedString(source.call_id) || itemId,
+ name: asTrimmedString(source.name),
+ arguments: typeof source.arguments === 'string' ? source.arguments : ''
+ };
+ }
+ if (source.type === 'custom_tool_call') {
+ return {
+ ...source,
+ id: itemId,
+ call_id: asTrimmedString(source.call_id) || itemId,
+ name: asTrimmedString(source.name),
+ input: typeof source.input === 'string' ? source.input : ''
+ };
+ }
+ return { ...source, id: itemId };
+}
+
+function emitResponsesTextPartAdded(res, itemId, outputIndex, contentIndex, nextSeq) {
+ writeSse(res, 'response.content_part.added', {
+ type: 'response.content_part.added',
+ item_id: itemId,
+ output_index: outputIndex,
+ content_index: contentIndex,
+ part: { type: 'output_text', text: '', annotations: [], logprobs: [] },
+ sequence_number: nextSeq()
+ });
+}
+
+function emitResponsesTextPartDone(res, itemId, outputIndex, contentIndex, text, nextSeq) {
+ writeSse(res, 'response.content_part.done', {
+ type: 'response.content_part.done',
+ item_id: itemId,
+ output_index: outputIndex,
+ content_index: contentIndex,
+ part: { type: 'output_text', text: typeof text === 'string' ? text : '', annotations: [], logprobs: [] },
+ sequence_number: nextSeq()
+ });
+}
+
+function emitResponsesToolArgumentEvents(res, item, outputIndex, nextSeq) {
+ const eventType = item.type === 'custom_tool_call'
+ ? 'response.custom_tool_call_input.delta'
+ : 'response.function_call_arguments.delta';
+ const doneType = item.type === 'custom_tool_call'
+ ? ''
+ : 'response.function_call_arguments.done';
+ const value = item.type === 'custom_tool_call'
+ ? (typeof item.input === 'string' ? item.input : '')
+ : (typeof item.arguments === 'string' ? item.arguments : '');
+ if (value) {
+ writeSse(res, eventType, {
+ type: eventType,
+ item_id: item.id,
+ output_index: outputIndex,
+ call_id: item.call_id,
+ name: item.name,
+ delta: value,
+ sequence_number: nextSeq()
+ });
+ }
+ if (doneType) {
+ writeSse(res, doneType, {
+ type: doneType,
+ item_id: item.id,
+ output_index: outputIndex,
+ call_id: item.call_id,
+ name: item.name,
+ arguments: value,
+ sequence_number: nextSeq()
+ });
+ }
+}
+
+function appendChatStreamToolCall(target, toolCall) {
+ if (!toolCall || typeof toolCall !== 'object') return;
+ const index = Number.isFinite(toolCall.index) ? toolCall.index : target.length;
+ if (!target[index]) {
+ target[index] = {
+ id: '',
+ type: 'function',
+ function: { name: '', arguments: '' }
+ };
+ }
+ const current = target[index];
+ if (typeof toolCall.id === 'string' && toolCall.id) current.id = toolCall.id;
+ if (typeof toolCall.type === 'string' && toolCall.type) current.type = toolCall.type;
+ const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : null;
+ if (fn) {
+ if (typeof fn.name === 'string' && fn.name) current.function.name = fn.name;
+ if (typeof fn.arguments === 'string') current.function.arguments += fn.arguments;
+ }
+}
+
+function writeChatCompletionChunkAsResponsesSse(state, chunk) {
+ if (!chunk || typeof chunk !== 'object') return;
+ if (typeof chunk.model === 'string' && chunk.model) {
+ state.model = chunk.model;
+ }
+ const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
+ for (const choice of choices) {
+ const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
+ if (!delta) continue;
+
+ const reasoningSegment = typeof delta.reasoning_content === 'string'
+ ? delta.reasoning_content
+ : (typeof delta.reasoning === 'string'
+ ? delta.reasoning
+ : (typeof delta.reasoning_text === 'string' ? delta.reasoning_text : ''));
+ if (reasoningSegment) {
+ if (!state.reasoningItem) {
+ state.reasoningItem = {
+ id: `rs_${crypto.randomBytes(8).toString('hex')}`,
+ type: 'reasoning',
+ summary: [{ type: 'summary_text', text: '' }]
+ };
+ state.output.push(state.reasoningItem);
+ writeSse(state.res, 'response.output_item.added', {
+ type: 'response.output_item.added',
+ output_index: state.output.length - 1,
+ item: buildResponsesSseItem(state.reasoningItem, state.reasoningItem.id)
+ });
+ }
+ state.reasoningText += reasoningSegment;
+ state.reasoningItem.summary[0].text = state.reasoningText;
+ writeSse(state.res, 'response.reasoning_summary_text.delta', {
+ type: 'response.reasoning_summary_text.delta',
+ item_id: state.reasoningItem.id,
+ output_index: state.output.indexOf(state.reasoningItem),
+ summary_index: 0,
+ delta: reasoningSegment,
+ sequence_number: state.nextSeq()
+ });
+ }
+
+ const segments = [];
+ // DeepSeek-style OpenAI-compatible streams may emit private reasoning in
+ // `reasoning_content` before the final answer. Responses `output_text`
+ // must stay user-visible answer text only; forwarding reasoning here
+ // pollutes Codex output and breaks exact-answer prompts.
+ if (typeof delta.content === 'string' && delta.content) {
+ segments.push(delta.content);
+ }
+ for (const seg of segments) {
+ if (!state.messageItem) {
+ state.messageItem = {
+ id: `msg_${crypto.randomBytes(8).toString('hex')}`,
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'output_text', text: '' }]
+ };
+ state.output.push(state.messageItem);
+ writeSse(state.res, 'response.output_item.added', {
+ type: 'response.output_item.added',
+ output_index: state.output.length - 1,
+ item: buildResponsesSseItem(state.messageItem, state.messageItem.id)
+ });
+ emitResponsesTextPartAdded(state.res, state.messageItem.id, state.output.length - 1, 0, state.nextSeq);
+ }
+ state.messageText += seg;
+ state.messageItem.content[0].text = state.messageText;
+ writeSse(state.res, 'response.output_text.delta', {
+ type: 'response.output_text.delta',
+ item_id: state.messageItem.id,
+ output_index: state.output.length - 1,
+ content_index: 0,
+ delta: seg,
+ sequence_number: state.nextSeq()
+ });
+ }
+
+ if (Array.isArray(delta.tool_calls)) {
+ for (const toolCall of delta.tool_calls) {
+ appendChatStreamToolCall(state.toolCalls, toolCall);
+ }
+ }
+
+ if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
+ state.sawFinishReason = true;
+ }
+ }
+}
+
+function finishChatStreamResponsesSse(state) {
+ if (!state || state.finished) return;
+ state.finished = true;
+
+ if (state.reasoningItem) {
+ const outputIndex = state.output.indexOf(state.reasoningItem);
+ writeSse(state.res, 'response.reasoning_summary_text.done', {
+ type: 'response.reasoning_summary_text.done',
+ item_id: state.reasoningItem.id,
+ output_index: outputIndex,
+ summary_index: 0,
+ text: state.reasoningText,
+ sequence_number: state.nextSeq()
+ });
+ writeSse(state.res, 'response.output_item.done', {
+ type: 'response.output_item.done',
+ output_index: outputIndex,
+ item: buildResponsesSseItem(state.reasoningItem, state.reasoningItem.id),
+ sequence_number: state.nextSeq()
+ });
+ }
+
+ if (state.messageItem) {
+ const outputIndex = state.output.indexOf(state.messageItem);
+ writeSse(state.res, 'response.output_text.done', {
+ type: 'response.output_text.done',
+ item_id: state.messageItem.id,
+ output_index: outputIndex,
+ content_index: 0,
+ text: state.messageText,
+ sequence_number: state.nextSeq()
+ });
+ emitResponsesTextPartDone(state.res, state.messageItem.id, outputIndex, 0, state.messageText, state.nextSeq);
+ writeSse(state.res, 'response.output_item.done', {
+ type: 'response.output_item.done',
+ output_index: outputIndex,
+ item: buildResponsesSseItem(state.messageItem, state.messageItem.id),
+ sequence_number: state.nextSeq()
+ });
+ }
+
+ for (const toolCall of state.toolCalls) {
+ if (!toolCall) continue;
+ const item = buildResponsesToolCallItemFromChatToolCall(toolCall, state.toolTypesByName || {});
+ if (!item) continue;
+ const outputIndex = state.output.length;
+ state.output.push(item);
+ writeSse(state.res, 'response.output_item.added', {
+ type: 'response.output_item.added',
+ output_index: outputIndex,
+ item: buildResponsesSseItem(item, item.call_id)
+ });
+ emitResponsesToolArgumentEvents(state.res, buildResponsesSseItem(item, item.call_id), outputIndex, state.nextSeq);
+ writeSse(state.res, 'response.output_item.done', {
+ type: 'response.output_item.done',
+ output_index: outputIndex,
+ item: buildResponsesSseItem(item, item.call_id),
+ sequence_number: state.nextSeq()
+ });
+ }
+
+ const response = ensureResponseMetadata({
+ id: state.responseId,
+ model: state.model,
+ created_at: state.createdAt,
+ status: 'completed',
+ output: state.output,
+ output_text: state.messageText
+ });
+ writeSse(state.res, 'response.completed', { type: 'response.completed', response });
+ writeSse(state.res, 'done', '[DONE]');
+ if (!state.res.writableEnded && !state.res.destroyed) {
+ state.res.end();
+ }
+}
+
+function failChatStreamResponsesSse(state, errorMessage) {
+ if (!state || state.finished) return;
+ state.finished = true;
+ writeSse(state.res, 'response.failed', {
+ type: 'response.failed',
+ response: ensureResponseMetadata({
+ id: state.responseId,
+ model: state.model,
+ created_at: state.createdAt,
+ status: 'failed',
+ output: state.output,
+ output_text: state.messageText
+ }),
+ error: String(errorMessage || 'upstream stream failed')
+ });
+ writeSse(state.res, 'done', '[DONE]');
+ if (!state.res.writableEnded && !state.res.destroyed) {
+ state.res.end();
+ }
+}
+
+function formatUpstreamStreamError(errorValue) {
+ if (!errorValue) return 'upstream stream failed';
+ if (typeof errorValue === 'string') return errorValue;
+ if (typeof errorValue === 'object') {
+ if (typeof errorValue.message === 'string' && errorValue.message) return errorValue.message;
+ try { return JSON.stringify(errorValue); } catch (_) {}
+ }
+ return String(errorValue || 'upstream stream failed');
+}
+
+function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
+ const parsed = new URL(targetUrl);
+ const transport = parsed.protocol === 'https:' ? https : http;
+ const bodyText = options.body ? JSON.stringify(options.body) : '';
+ const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
+ ? Math.floor(options.maxBytes)
+ : 0;
+ const headers = {
+ 'Accept': 'text/event-stream',
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
+ ...(options.headers || {})
+ };
+ if (options.body) {
+ headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
+ }
+ const timeoutMs = Number.isFinite(options.timeoutMs)
+ ? Math.max(1000, Number(options.timeoutMs))
+ : STREAM_IDLE_TIMEOUT_MS;
+ const res = options.res;
+ const fallbackModel = typeof options.model === 'string' ? options.model : '';
+
+ return new Promise((resolve) => {
+ let settled = false;
+ let upstreamReq = null;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ resolve(value);
+ };
+ const abortUpstream = () => {
+ if (upstreamReq) {
+ try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {}
+ }
+ };
+ if (res && typeof res.once === 'function') {
+ res.once('close', abortUpstream);
+ }
+
+ upstreamReq = transport.request({
+ protocol: parsed.protocol,
+ hostname: parsed.hostname,
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
+ method: options.method || 'POST',
+ path: `${parsed.pathname}${parsed.search}`,
+ headers,
+ agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
+ }, (upstreamRes) => {
+ const status = upstreamRes.statusCode || 0;
+ const chunks = [];
+ let size = 0;
+ const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
+
+ const collectChunk = (chunk) => {
+ if (!chunk) return true;
+ if (maxBytes > 0) {
+ size += chunk.length;
+ if (size > maxBytes) {
+ chunks.length = 0;
+ try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
+ try { upstreamReq.destroy(new Error('response too large')); } catch (_) {}
+ finish({ ok: false, status, error: 'response too large' });
+ return false;
+ }
+ }
+ chunks.push(chunk);
+ return true;
+ };
+
+ if (status >= 400) {
+ upstreamRes.on('data', collectChunk);
+ upstreamRes.on('end', () => finish({
+ ok: false,
+ status,
+ bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
+ }));
+ return;
+ }
+
+ if (!res.headersSent) {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ 'X-Accel-Buffering': 'no'
+ });
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
+ }
+
+ if (!/text\/event-stream/i.test(contentType)) {
+ upstreamRes.on('data', collectChunk);
+ upstreamRes.on('end', () => {
+ const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
+ const parsedJson = parseJsonOrError(text);
+ if (parsedJson.error) {
+ writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
+ writeSse(res, 'done', '[DONE]');
+ if (!res.writableEnded && !res.destroyed) res.end();
+ finish({ ok: true });
+ return;
+ }
+ const extracted = extractChatCompletionResult(parsedJson.value);
+ sendResponsesSse(res, buildResponsesPayloadFromChatResult(fallbackModel, extracted.text, extracted.toolCalls, parsedJson.value, {
+ toolTypesByName: options.toolTypesByName || {}
+ }));
+ if (!res.writableEnded && !res.destroyed) res.end();
+ finish({ ok: true });
+ });
+ return;
+ }
+
+ let sequence = 0;
+ const state = {
+ res,
+ responseId: `resp_${crypto.randomBytes(10).toString('hex')}`,
+ model: fallbackModel,
+ createdAt: Math.floor(Date.now() / 1000),
+ output: [],
+ messageItem: null,
+ messageText: '',
+ reasoningItem: null,
+ reasoningText: '',
+ toolCalls: [],
+ toolTypesByName: options.toolTypesByName || {},
+ finished: false,
+ sawDone: false,
+ sawFinishReason: false,
+ nextSeq: () => {
+ sequence += 1;
+ return sequence;
+ }
+ };
+ writeSse(res, 'response.created', {
+ type: 'response.created',
+ response: {
+ id: state.responseId,
+ model: state.model,
+ created_at: state.createdAt
+ }
+ });
+
+ let buffer = '';
+ const utf8Decoder = new StringDecoder('utf8');
+ const handleEventBlock = (block) => {
+ const dataLines = String(block || '')
+ .split(/\r?\n/)
+ .filter((line) => line.startsWith('data:'))
+ .map((line) => line.slice(5).trimStart());
+ if (dataLines.length === 0) return;
+ const data = dataLines.join('\n').trim();
+ if (!data) return;
+ if (data === '[DONE]') {
+ state.sawDone = true;
+ finishChatStreamResponsesSse(state);
+ finish({ ok: true });
+ return;
+ }
+ const parsedChunk = parseJsonOrError(data);
+ if (!parsedChunk.error) {
+ if (parsedChunk.value && typeof parsedChunk.value === 'object' && parsedChunk.value.error) {
+ failChatStreamResponsesSse(state, formatUpstreamStreamError(parsedChunk.value.error));
+ finish({ ok: true });
+ return;
+ }
+ writeChatCompletionChunkAsResponsesSse(state, parsedChunk.value);
+ }
+ };
+
+ upstreamRes.on('data', (chunk) => {
+ if (!chunk) return;
+ buffer += utf8Decoder.write(chunk);
+ let boundary = buffer.search(/\r?\n\r?\n/);
+ while (boundary >= 0) {
+ const block = buffer.slice(0, boundary);
+ const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
+ buffer = buffer.slice(boundary + (match ? match[0].length : 2));
+ handleEventBlock(block);
+ boundary = buffer.search(/\r?\n\r?\n/);
+ }
+ });
+ upstreamRes.on('end', () => {
+ buffer += utf8Decoder.end();
+ if (buffer.trim()) handleEventBlock(buffer);
+ if (!state.finished && !state.sawDone && !state.sawFinishReason) {
+ failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]');
+ finish({ ok: true });
+ return;
+ }
+ finishChatStreamResponsesSse(state);
+ finish({ ok: true });
+ });
+ upstreamRes.on('aborted', () => {
+ failChatStreamResponsesSse(state, 'upstream stream aborted');
+ finish({ ok: true });
+ });
+ upstreamRes.on('error', (err) => {
+ failChatStreamResponsesSse(state, err && err.message ? err.message : 'upstream stream failed');
+ finish({ ok: true });
+ });
+ });
+ upstreamReq.setTimeout(timeoutMs, () => {
+ try { upstreamReq.destroy(new Error('timeout')); } catch (_) {}
+ finish({ ok: false, error: 'timeout' });
+ });
+ upstreamReq.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
+ if (bodyText) upstreamReq.write(bodyText);
+ upstreamReq.end();
+ });
+}
+
+function streamResponsesSse(targetUrl, options = {}) {
+ const parsed = new URL(targetUrl);
+ const transport = parsed.protocol === 'https:' ? https : http;
+ const bodyText = options.body ? JSON.stringify(options.body) : '';
+ const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
+ ? Math.floor(options.maxBytes)
+ : 0;
+ const headers = {
+ 'Accept': 'text/event-stream',
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
+ ...(options.headers || {})
+ };
+ if (options.body) {
+ headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
+ }
+ const timeoutMs = Number.isFinite(options.timeoutMs)
+ ? Math.max(1000, Number(options.timeoutMs))
+ : STREAM_IDLE_TIMEOUT_MS;
+ const res = options.res;
+
+ return new Promise((resolve) => {
+ let settled = false;
+ let upstreamReq = null;
+ let streamAccepted = false;
+ let streamIdleTimer = null;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ if (streamIdleTimer) {
+ clearTimeout(streamIdleTimer);
+ streamIdleTimer = null;
+ }
+ resolve(value);
+ };
+ const abortUpstream = () => {
+ if (upstreamReq) {
+ try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {}
+ }
+ };
+ if (res && typeof res.once === 'function') {
+ res.once('close', abortUpstream);
+ }
+
+ upstreamReq = transport.request({
+ protocol: parsed.protocol,
+ hostname: parsed.hostname,
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
+ method: options.method || 'POST',
+ path: `${parsed.pathname}${parsed.search}`,
+ headers,
+ agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
+ }, (upstreamRes) => {
+ const status = upstreamRes.statusCode || 0;
+ const chunks = [];
+ let size = 0;
+ const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
+ const collectChunk = (chunk) => {
+ if (!chunk) return true;
+ if (maxBytes > 0) {
+ size += chunk.length;
+ if (size > maxBytes) {
+ chunks.length = 0;
+ try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
+ try { upstreamReq.destroy(new Error('response too large')); } catch (_) {}
+ finish({ ok: false, status, error: 'response too large' });
+ return false;
+ }
+ }
+ chunks.push(chunk);
+ return true;
+ };
+ const bodyFromChunks = () => (chunks.length ? Buffer.concat(chunks).toString('utf-8') : '');
+
+ if (status === 404 || status === 405) {
+ upstreamRes.on('data', collectChunk);
+ upstreamRes.on('end', () => finish({ retry: true, status, bodyText: bodyFromChunks() }));
+ return;
+ }
+
+ if (status >= 400) {
+ upstreamRes.on('data', collectChunk);
+ upstreamRes.on('end', () => finish({ ok: false, status, bodyText: bodyFromChunks() }));
+ return;
+ }
+
+ if (/text\/event-stream/i.test(contentType)) {
+ streamAccepted = true;
+ upstreamReq.setTimeout(0);
+ const failAcceptedStream = (message) => {
+ if (!res.writableEnded && !res.destroyed) {
+ writeSse(res, 'response.failed', { type: 'response.failed', error: message });
+ writeSse(res, 'done', '[DONE]');
+ res.end();
+ }
+ try { upstreamRes.destroy(new Error(message)); } catch (_) {}
+ try { upstreamReq.destroy(new Error(message)); } catch (_) {}
+ finish({ ok: true });
+ };
+ const resetStreamIdleTimer = () => {
+ if (streamIdleTimer) clearTimeout(streamIdleTimer);
+ streamIdleTimer = setTimeout(() => {
+ failAcceptedStream('upstream stream idle timeout');
+ }, timeoutMs);
+ if (typeof streamIdleTimer.unref === 'function') streamIdleTimer.unref();
+ };
+ if (!res.headersSent) {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ 'X-Accel-Buffering': 'no'
+ });
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
+ }
+ resetStreamIdleTimer();
+ upstreamRes.on('data', (chunk) => {
+ resetStreamIdleTimer();
+ if (chunk && !res.writableEnded && !res.destroyed) res.write(chunk);
+ });
+ upstreamRes.on('end', () => {
+ if (!res.writableEnded && !res.destroyed) res.end();
+ finish({ ok: true });
+ });
+ upstreamRes.on('aborted', () => {
+ if (!res.writableEnded && !res.destroyed) {
+ writeSse(res, 'response.failed', { type: 'response.failed', error: 'upstream stream aborted' });
+ writeSse(res, 'done', '[DONE]');
+ res.end();
+ }
+ finish({ ok: true });
+ });
+ upstreamRes.on('error', (err) => {
+ if (!res.writableEnded && !res.destroyed) {
+ writeSse(res, 'response.failed', { type: 'response.failed', error: err && err.message ? err.message : 'upstream stream failed' });
+ writeSse(res, 'done', '[DONE]');
+ res.end();
+ }
+ finish({ ok: true });
+ });
+ return;
+ }
+
+ upstreamRes.on('data', collectChunk);
+ upstreamRes.on('end', () => {
+ const text = bodyFromChunks();
+ const parsedJson = parseJsonOrError(text);
+ if (!res.headersSent) {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ 'X-Accel-Buffering': 'no'
+ });
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
+ }
+ if (parsedJson.error) {
+ writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
+ writeSse(res, 'done', '[DONE]');
+ if (!res.writableEnded && !res.destroyed) res.end();
+ finish({ ok: true });
+ return;
+ }
+ sendResponsesSse(res, ensureResponseMetadata(parsedJson.value));
+ if (!res.writableEnded && !res.destroyed) res.end();
+ finish({ ok: true });
+ });
+ });
+ upstreamReq.setTimeout(timeoutMs, () => {
+ if (streamAccepted) return;
+ try { upstreamReq.destroy(new Error('timeout')); } catch (_) {}
+ finish({ ok: false, error: 'timeout' });
+ });
+ upstreamReq.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
+ if (bodyText) upstreamReq.write(bodyText);
+ upstreamReq.end();
+ });
+}
+
+async function proxyRequestJson(targetUrl, options = {}) {
+ const parsed = new URL(targetUrl);
+ const transport = parsed.protocol === 'https:' ? https : http;
+ const bodyText = options.body ? JSON.stringify(options.body) : '';
+ const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
+ ? Math.floor(options.maxBytes)
+ : 0;
+ const headers = {
+ 'Accept': 'application/json',
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
+ ...(options.headers || {})
+ };
+ if (options.body) {
+ headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
+ }
+
+ const timeoutMs = Number.isFinite(options.timeoutMs)
+ ? Math.max(1000, Number(options.timeoutMs))
+ : REQUEST_TIMEOUT_MS;
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ resolve(value);
+ };
+ const req = transport.request({
+ protocol: parsed.protocol,
+ hostname: parsed.hostname,
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
+ method: options.method || 'GET',
+ path: `${parsed.pathname}${parsed.search}`,
+ headers,
+ agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
+ }, (upstreamRes) => {
+ const chunks = [];
+ let size = 0;
+ upstreamRes.on('data', (chunk) => {
+ if (!chunk) return;
+ if (maxBytes > 0) {
+ size += chunk.length;
+ if (size > maxBytes) {
+ chunks.length = 0;
+ try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
+ try { req.destroy(new Error('response too large')); } catch (_) {}
+ finish({ ok: false, error: 'response too large' });
+ return;
+ }
+ }
+ chunks.push(chunk);
+ });
+ upstreamRes.on('end', () => {
+ const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
+ finish({
+ ok: true,
+ status: upstreamRes.statusCode || 0,
+ headers: upstreamRes.headers || {},
+ bodyText: text
+ });
+ });
+ });
+ req.setTimeout(timeoutMs, () => {
+ try { req.destroy(new Error('timeout')); } catch (_) {}
+ finish({ ok: false, error: 'timeout' });
+ });
+ req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
+ if (bodyText) {
+ req.write(bodyText);
+ }
+ req.end();
+ });
+}
+
+
+module.exports = {
+ normalizeBridgeMaxRetries,
+ parseJsonOrError,
+ extractChatCompletionResult,
+ convertResponsesRequestToChatCompletions,
+ buildResponsesPayloadFromChatResult,
+ ensureResponseMetadata,
+ sendResponsesSse,
+ extractResponsesOutputText,
+ normalizeResponsesPayloadForUpstream,
+ toUpstreamNonStreamingResponsesPayload,
+ shouldFallbackFromUpstreamResponses,
+ isResponsesEndpointUnsupported,
+ retryTransientRequest,
+ isTransientNetworkError,
+ isLoopbackAddress,
+ streamChatCompletionsAsResponsesSse,
+ streamResponsesSse,
+ proxyRequestJson
+};
diff --git a/cli/openai-bridge.js b/cli/openai-bridge.js
index 33f62fa4..c82cdd9c 100644
--- a/cli/openai-bridge.js
+++ b/cli/openai-bridge.js
@@ -1,7 +1,5 @@
const http = require('http');
-const https = require('https');
const crypto = require('crypto');
-const { StringDecoder } = require('string_decoder');
const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils');
const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils');
@@ -88,6 +86,26 @@ function normalizeOpenaiUpstreamBaseUrl(rawValue) {
}
}
+const {
+ normalizeBridgeMaxRetries,
+ parseJsonOrError,
+ extractChatCompletionResult,
+ convertResponsesRequestToChatCompletions,
+ buildResponsesPayloadFromChatResult,
+ ensureResponseMetadata,
+ sendResponsesSse,
+ extractResponsesOutputText,
+ normalizeResponsesPayloadForUpstream,
+ toUpstreamNonStreamingResponsesPayload,
+ shouldFallbackFromUpstreamResponses,
+ isResponsesEndpointUnsupported,
+ retryTransientRequest,
+ isTransientNetworkError,
+ isLoopbackAddress,
+ streamChatCompletionsAsResponsesSse,
+ streamResponsesSse,
+ proxyRequestJson
+} = require('./openai-bridge-runtime');
function normalizeUpstreamEntry(entry) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return null;
@@ -96,10 +114,11 @@ function normalizeUpstreamEntry(entry) {
const apiKey = normalizeText(entry.apiKey || entry.api_key || entry.key || '');
const headersRaw = entry.headers || entry.extraHeaders || entry.extra_headers || null;
const headers = normalizeHeadersMap(headersRaw);
+ const maxRetries = normalizeBridgeMaxRetries(entry.maxRetries ?? entry.max_retries);
if (!baseUrl || !isValidHttpUrl(baseUrl)) {
return null;
}
- return { baseUrl, apiKey, headers };
+ return { baseUrl, apiKey, headers, maxRetries };
}
function normalizeHeadersMap(value) {
@@ -145,11 +164,12 @@ function readOpenaiBridgeSettings(filePath) {
};
}
-function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, apiKey, headers) {
+function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, apiKey, headers, options = {}) {
const name = normalizeProviderName(providerName);
const baseUrl = normalizeOpenaiUpstreamBaseUrl(upstreamBaseUrl);
const key = normalizeText(apiKey);
const nextHeaders = normalizeHeadersMap(headers);
+ const maxRetries = normalizeBridgeMaxRetries(options && options.maxRetries);
if (!name) {
return { error: 'Provider name is required' };
@@ -170,7 +190,8 @@ function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, api
[name]: {
baseUrl,
apiKey: key,
- headers: Object.keys(nextHeaders).length ? nextHeaders : existingHeaders
+ headers: Object.keys(nextHeaders).length ? nextHeaders : existingHeaders,
+ maxRetries
}
}
};
@@ -223,1831 +244,6 @@ function readRequestBody(req, maxBytes) {
});
}
-function parseJsonOrError(text) {
- if (typeof text !== 'string' || !text.trim()) {
- return { value: null, error: 'empty body' };
- }
- try {
- return { value: JSON.parse(text), error: '' };
- } catch (e) {
- return { value: null, error: e && e.message ? e.message : 'invalid json' };
- }
-}
-
-function extractChatCompletionResult(payload) {
- if (!payload || typeof payload !== 'object') return { text: '', toolCalls: [] };
- const choice = Array.isArray(payload.choices) ? payload.choices[0] : null;
- const message = choice && typeof choice === 'object' ? choice.message : null;
- const toolCalls = message && typeof message === 'object' && Array.isArray(message.tool_calls)
- ? message.tool_calls
- : [];
- const content = message && typeof message === 'object' ? message.content : '';
- let text = '';
- if (typeof content === 'string') {
- text = content;
- } else if (Array.isArray(content)) {
- text = content
- .map((item) => {
- if (!item) return '';
- if (typeof item === 'string') return item;
- if (typeof item === 'object') {
- if (typeof item.text === 'string') return item.text;
- if (typeof item.content === 'string') return item.content;
- }
- return '';
- })
- .filter(Boolean)
- .join('');
- }
- return { text, toolCalls };
-}
-
-function stringifyJsonValue(value, fallback = '') {
- if (typeof value === 'string') return value;
- if (value == null) return fallback;
- try {
- return JSON.stringify(value);
- } catch (_) {
- return fallback;
- }
-}
-
-function parseJsonValueOrNull(value) {
- if (typeof value !== 'string') return null;
- const text = value.trim();
- if (!text) return null;
- try {
- return JSON.parse(text);
- } catch (_) {
- return null;
- }
-}
-
-function isRecord(value) {
- return !!value && typeof value === 'object' && !Array.isArray(value);
-}
-
-function asTrimmedString(value) {
- return typeof value === 'string' ? value.trim() : '';
-}
-
-function cloneJsonValue(value) {
- if (Array.isArray(value)) return value.map((item) => cloneJsonValue(item));
- if (isRecord(value)) {
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]));
- }
- return value;
-}
-
-function normalizeResponsesToolOutput(value) {
- if (typeof value === 'string') return value || '(empty)';
- if (value == null) return '(empty)';
- return stringifyJsonValue(value, '(empty)') || '(empty)';
-}
-
-function chatContentToPlainText(content) {
- if (typeof content === 'string') return content;
- if (!Array.isArray(content)) return '';
- return content
- .map((item) => {
- if (typeof item === 'string') return item;
- if (!isRecord(item)) return '';
- if (typeof item.text === 'string') return item.text;
- if (typeof item.content === 'string') return item.content;
- return '';
- })
- .join('');
-}
-
-function wrapReasoningContentForChat(content) {
- const text = chatContentToPlainText(content).trim();
- return text ? `${text}` : '';
-}
-
-function normalizeOpenAiToolArguments(value) {
- if (typeof value === 'string') return value;
- if (value == null) return '{}';
- return stringifyJsonValue(value, '{}');
-}
-
-function normalizeInputFileBlock(item) {
- if (!isRecord(item)) return null;
- const file = isRecord(item.file) ? item.file : item;
- const out = {};
- const fileId = asTrimmedString(file.file_id || file.id);
- const filename = asTrimmedString(file.filename || file.name);
- const fileData = asTrimmedString(file.file_data || file.data);
- const mimeType = asTrimmedString(file.mime_type || file.media_type);
- if (fileId) out.file_id = fileId;
- if (filename) out.filename = filename;
- if (fileData) out.file_data = fileData;
- if (mimeType) out.mime_type = mimeType;
- return Object.keys(out).length > 0 ? out : null;
-}
-
-function normalizeResponsesContentBlockForChat(item) {
- if (typeof item === 'string') return item.trim() ? item : null;
- if (!isRecord(item)) return null;
-
- const type = asTrimmedString(item.type).toLowerCase();
- if (!type) {
- const text = asTrimmedString(item.text || item.content || item.output_text);
- return text ? { type: 'text', text } : null;
- }
-
- if (type === 'input_text' || type === 'output_text' || type === 'text' || type === 'summary_text' || type === 'reasoning_text') {
- const text = typeof item.text === 'string' ? item.text : asTrimmedString(item.content || item.output_text);
- return text ? { type: 'text', text } : null;
- }
-
- if (type === 'refusal' && typeof item.refusal === 'string') {
- return item.refusal ? { type: 'text', text: item.refusal } : null;
- }
-
- if (type === 'input_image') {
- const raw = item.image_url != null ? item.image_url : (item.url != null ? item.url : item.imageUrl);
- if (raw === undefined) return null;
- return {
- type: 'image_url',
- image_url: typeof raw === 'string' ? { url: raw } : cloneJsonValue(raw)
- };
- }
-
- if (type === 'image_url' && item.image_url !== undefined) {
- return { type: 'image_url', image_url: item.image_url };
- }
-
- if (type === 'input_audio') {
- if (item.input_audio !== undefined) return { type: 'input_audio', input_audio: item.input_audio };
- if (item.data !== undefined || item.format !== undefined) {
- return { type: 'input_audio', input_audio: { data: item.data, format: item.format } };
- }
- return null;
- }
-
- if (type === 'input_file' || type === 'file') {
- const file = normalizeInputFileBlock(item);
- return file ? { type: 'file', file } : null;
- }
-
- if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
- const text = asTrimmedString(item.text || item.content);
- return text ? { type: 'text', text } : null;
- }
-
- const text = asTrimmedString(item.text || item.content);
- return text ? { type: 'text', text } : cloneJsonValue(item);
-}
-
-function toOpenAiMessageContent(content) {
- if (typeof content === 'string') return content;
- if (!Array.isArray(content)) {
- if (isRecord(content)) {
- const single = normalizeResponsesContentBlockForChat(content);
- if (!single) return '';
- return typeof single === 'string' ? single : [single];
- }
- return '';
- }
-
- const blocks = content
- .map((item) => normalizeResponsesContentBlockForChat(item))
- .filter((item) => !!item);
-
- if (blocks.length === 0) return '';
- if (blocks.length === 1 && typeof blocks[0] === 'string') return blocks[0];
- return blocks;
-}
-
-const RESPONSES_TOOL_CALL_INPUT_TYPES = new Set(['function_call', 'custom_tool_call', 'mcp_tool_call', 'local_shell_call']);
-const RESPONSES_TOOL_CALL_OUTPUT_TYPES = new Set(['function_call_output', 'custom_tool_call_output', 'mcp_tool_call_output', 'tool_search_output', 'local_shell_call_output']);
-
-function stripOrphanedResponsesToolOutputs(input) {
- if (!Array.isArray(input)) return input;
- const seenToolCallIds = new Set();
- const sanitized = [];
- for (const item of input) {
- if (!isRecord(item)) {
- sanitized.push(item);
- continue;
- }
- const type = asTrimmedString(item.type).toLowerCase();
- if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(type)) {
- const callId = asTrimmedString(item.call_id || item.id);
- if (callId) seenToolCallIds.add(callId);
- sanitized.push(item);
- continue;
- }
- if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(type)) {
- const callId = asTrimmedString(item.call_id || item.id);
- if (!callId || !seenToolCallIds.has(callId)) continue;
- sanitized.push(item);
- continue;
- }
- sanitized.push(item);
- }
- return sanitized;
-}
-
-function normalizeFreeformToolArguments(value) {
- if (typeof value === 'string') return stringifyJsonValue({ input: value }, '{"input":""}');
- if (value == null) return '{"input":""}';
- if (isRecord(value) && Object.prototype.hasOwnProperty.call(value, 'input')) {
- return stringifyJsonValue(value, '{"input":""}');
- }
- return stringifyJsonValue({ input: normalizeResponsesToolOutput(value) }, '{"input":""}');
-}
-
-function toOpenAiToolCall(item, fallbackIndex) {
- if (!isRecord(item)) return null;
- const callId = asTrimmedString(item.call_id || item.id) || `call_${crypto.randomBytes(8).toString('hex')}_${fallbackIndex}`;
- const type = asTrimmedString(item.type).toLowerCase();
- const name = asTrimmedString(item.name)
- || asTrimmedString(item.server_label)
- || (type === 'local_shell_call' ? 'local_shell' : '');
- if (!name) return null;
- const rawArguments = item.arguments != null
- ? item.arguments
- : (item.input != null ? item.input : (item.action != null ? item.action : item.command));
- const args = (type === 'custom_tool_call' && item.arguments == null)
- ? normalizeFreeformToolArguments(rawArguments)
- : normalizeOpenAiToolArguments(rawArguments);
- return {
- id: callId,
- type: 'function',
- function: {
- name,
- arguments: args
- }
- };
-}
-
-function hasOpenAiMessageContent(content) {
- return typeof content === 'string'
- ? content.trim().length > 0
- : Array.isArray(content) && content.length > 0;
-}
-
-function normalizeResponsesInputToChatMessages(input) {
- // Keep the OpenAI bridge in lockstep with the builtin proxy's Responses → Chat shim.
- // Codex long-running tasks append richer Responses history (custom/local_shell/MCP calls)
- // back into `input`; dropping those items makes the next model turn lose tool state and stop early.
- const messages = [];
- const normalizedInput = stripOrphanedResponsesToolOutputs(input);
- let functionCallIndex = 0;
- let pendingToolCalls = [];
- const emittedToolCallIds = new Set();
-
- const flushPendingToolCalls = () => {
- if (pendingToolCalls.length <= 0) return;
- for (const toolCall of pendingToolCalls) {
- const callId = asTrimmedString(toolCall.id);
- if (callId) emittedToolCallIds.add(callId);
- }
- messages.push({
- role: 'assistant',
- content: null,
- tool_calls: pendingToolCalls
- });
- pendingToolCalls = [];
- };
-
- const pushToolOutputMessage = (callIdRaw, outputRaw) => {
- const toolCallId = asTrimmedString(callIdRaw);
- if (!toolCallId) return;
- messages.push({
- role: 'tool',
- tool_call_id: toolCallId,
- content: normalizeResponsesToolOutput(outputRaw)
- });
- };
-
- const processInputItem = (item) => {
- if (typeof item === 'string') {
- flushPendingToolCalls();
- const text = item.trim();
- if (text) messages.push({ role: 'user', content: text });
- return;
- }
- if (!isRecord(item)) return;
-
- const itemType = asTrimmedString(item.type).toLowerCase();
- if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(itemType)) {
- const toolCall = toOpenAiToolCall(item, functionCallIndex);
- functionCallIndex += 1;
- if (toolCall) pendingToolCalls.push(toolCall);
- return;
- }
-
- if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(itemType)) {
- flushPendingToolCalls();
- const toolCallId = asTrimmedString(item.call_id || item.id);
- if (!toolCallId || !emittedToolCallIds.has(toolCallId)) return;
- pushToolOutputMessage(toolCallId, item.output != null ? item.output : item.content);
- return;
- }
-
- if (itemType === 'reasoning') {
- flushPendingToolCalls();
- const reasoningContent = wrapReasoningContentForChat(toOpenAiMessageContent(item.summary != null ? item.summary : (item.content != null ? item.content : item)));
- const reasoningSignature = asTrimmedString(item.encrypted_content || item.reasoning_signature);
- if (!hasOpenAiMessageContent(reasoningContent) && !reasoningSignature) return;
- const message = { role: 'assistant', content: reasoningContent };
- if (reasoningSignature) message.reasoning_signature = reasoningSignature;
- messages.push(message);
- return;
- }
-
- flushPendingToolCalls();
- const role = asTrimmedString(item.role).toLowerCase() || 'user';
- const normalizedRole = role === 'developer' ? 'system' : role;
- const content = toOpenAiMessageContent(item.content != null ? item.content : (item.input != null ? item.input : item));
-
- if (normalizedRole === 'tool') {
- const toolCallId = asTrimmedString(item.tool_call_id || item.call_id || item.id);
- if (!toolCallId || !emittedToolCallIds.has(toolCallId)) return;
- pushToolOutputMessage(toolCallId, item.content);
- return;
- }
-
- if (!hasOpenAiMessageContent(content)) return;
- const message = { role: normalizedRole, content };
- const phase = asTrimmedString(item.phase);
- if (phase) message.phase = phase;
- messages.push(message);
- };
-
- if (typeof normalizedInput === 'string') {
- const text = normalizedInput.trim();
- if (text) messages.push({ role: 'user', content: text });
- } else if (Array.isArray(normalizedInput)) {
- for (const item of normalizedInput) processInputItem(item);
- } else if (isRecord(normalizedInput)) {
- processInputItem(normalizedInput);
- }
- flushPendingToolCalls();
- return messages;
-}
-
-function normalizeFunctionToolForChat(tool) {
- if (!isRecord(tool)) return null;
- const sourceFn = isRecord(tool.function) ? tool.function : tool;
- const name = asTrimmedString(sourceFn.name) || asTrimmedString(tool.name);
- if (!name) return null;
- const fn = { name };
- const description = asTrimmedString(sourceFn.description) || asTrimmedString(tool.description);
- if (description) fn.description = description;
- if (sourceFn.parameters !== undefined) {
- fn.parameters = cloneJsonValue(sourceFn.parameters);
- } else if (tool.parameters !== undefined) {
- fn.parameters = cloneJsonValue(tool.parameters);
- }
- if (typeof sourceFn.strict === 'boolean') {
- fn.strict = sourceFn.strict;
- } else if (typeof tool.strict === 'boolean') {
- fn.strict = tool.strict;
- }
- return { type: 'function', function: fn };
-}
-
-function buildLocalShellToolForChat(tool) {
- return {
- type: 'function',
- function: {
- name: asTrimmedString(tool && tool.name) || 'local_shell',
- description: asTrimmedString(tool && tool.description) || 'Run a local shell command and return its output.',
- parameters: {
- type: 'object',
- properties: {
- cmd: { type: 'string', description: 'Shell command to execute.' },
- yield_time_ms: { type: 'number', description: 'Milliseconds to wait before yielding partial output.' },
- max_output_tokens: { type: 'number', description: 'Maximum output tokens to return.' }
- },
- required: ['cmd'],
- additionalProperties: true
- }
- }
- };
-}
-
-function buildFreeformToolForChat(tool, fallbackName = 'custom_tool') {
- return {
- type: 'function',
- function: {
- name: asTrimmedString(tool && tool.name) || fallbackName,
- description: asTrimmedString(tool && tool.description) || 'Pass raw freeform input to the local tool.',
- parameters: {
- type: 'object',
- properties: {
- input: { type: 'string', description: 'Raw tool input.' }
- },
- required: ['input'],
- additionalProperties: false
- }
- }
- };
-}
-
-const MAX_RESPONSES_TOOL_NAMESPACE_DEPTH = 5;
-
-function rememberResponsesToolType(tool, target, depth = 0) {
- if (!isRecord(tool) || !target || depth > MAX_RESPONSES_TOOL_NAMESPACE_DEPTH) return;
- const type = asTrimmedString(tool.type).toLowerCase();
- if (type === 'namespace' && Array.isArray(tool.tools)) {
- for (const inner of tool.tools) rememberResponsesToolType(inner, target, depth + 1);
- return;
- }
- const sourceFn = isRecord(tool.function) ? tool.function : tool;
- const name = asTrimmedString(sourceFn.name) || asTrimmedString(tool.name);
- if (!name) return;
- if (type === 'local_shell') {
- target[name] = 'local_shell_call';
- return;
- }
- if (type === 'custom' || type === 'custom_tool' || (!type && name === 'apply_patch')) {
- target[name] = 'custom_tool_call';
- return;
- }
- if (type === 'function') {
- target[name] = 'function_call';
- }
-}
-
-function collectResponsesToolTypesByName(tools) {
- const result = {};
- if (!Array.isArray(tools)) return result;
- for (const tool of tools) rememberResponsesToolType(tool, result);
- return result;
-}
-
-function extractFreeformInputFromChatArguments(argumentsText) {
- if (typeof argumentsText !== 'string') return '';
- const parsed = parseJsonValueOrNull(argumentsText);
- if (isRecord(parsed) && Object.prototype.hasOwnProperty.call(parsed, 'input')) {
- return typeof parsed.input === 'string' ? parsed.input : normalizeResponsesToolOutput(parsed.input);
- }
- return argumentsText;
-}
-
-function extractLocalShellActionFromChatArguments(argumentsText) {
- const parsed = parseJsonValueOrNull(argumentsText);
- if (isRecord(parsed)) return cloneJsonValue(parsed);
- return { cmd: typeof argumentsText === 'string' ? argumentsText : '' };
-}
-
-function buildResponsesToolCallItemFromChatToolCall(toolCall, toolTypesByName = {}) {
- if (!isRecord(toolCall)) return null;
- const fn = isRecord(toolCall.function) ? toolCall.function : {};
- const name = asTrimmedString(fn.name);
- if (!name) return null;
- const callId = asTrimmedString(toolCall.id) || `call_${crypto.randomBytes(8).toString('hex')}`;
- const argumentsText = typeof fn.arguments === 'string' ? fn.arguments : '';
- const responseType = toolTypesByName && toolTypesByName[name] ? toolTypesByName[name] : 'function_call';
-
- if (responseType === 'custom_tool_call') {
- return {
- type: 'custom_tool_call',
- call_id: callId,
- name,
- input: extractFreeformInputFromChatArguments(argumentsText)
- };
- }
- if (responseType === 'local_shell_call') {
- return {
- type: 'local_shell_call',
- call_id: callId,
- name,
- action: extractLocalShellActionFromChatArguments(argumentsText)
- };
- }
- return {
- type: 'function_call',
- call_id: callId,
- name,
- arguments: argumentsText
- };
-}
-
-function normalizeSingleResponsesToolToChatTools(tool, depth = 0) {
- if (!isRecord(tool) || depth > MAX_RESPONSES_TOOL_NAMESPACE_DEPTH) return [];
- const type = asTrimmedString(tool.type).toLowerCase();
- if (type === 'namespace' && Array.isArray(tool.tools)) {
- return tool.tools.flatMap((inner) => normalizeSingleResponsesToolToChatTools(inner, depth + 1));
- }
- if (type === 'function') {
- const converted = normalizeFunctionToolForChat(tool);
- return converted ? [converted] : [];
- }
- if (type === 'local_shell') {
- return [buildLocalShellToolForChat(tool)];
- }
- const name = asTrimmedString(tool.name);
- if (type === 'custom' || type === 'custom_tool' || (!type && name === 'apply_patch')) {
- return [buildFreeformToolForChat(tool, name || 'custom_tool')];
- }
- return [];
-}
-
-function normalizeResponsesToolsToChatTools(tools) {
- if (!Array.isArray(tools)) return tools;
- return tools.flatMap((tool) => normalizeSingleResponsesToolToChatTools(tool));
-}
-
-function normalizeResponsesToolChoiceToChatToolChoice(toolChoice) {
- if (toolChoice === undefined) return undefined;
- if (typeof toolChoice === 'string') return toolChoice;
- if (!isRecord(toolChoice)) return toolChoice;
-
- const type = asTrimmedString(toolChoice.type).toLowerCase();
- if (type === 'tool' || type === 'function' || type === 'custom' || type === 'custom_tool' || type === 'local_shell') {
- if (isRecord(toolChoice.function) && asTrimmedString(toolChoice.function.name)) return cloneJsonValue(toolChoice);
- const name = asTrimmedString(toolChoice.name) || asTrimmedString(toolChoice.server_label);
- if (!name) return 'required';
- return { type: 'function', function: { name } };
- }
- if (type === 'auto' || type === 'none' || type === 'required') return type;
- return 'auto';
-}
-
-function getChatToolChoiceName(toolChoice) {
- if (!isRecord(toolChoice)) return '';
- if (isRecord(toolChoice.function)) return asTrimmedString(toolChoice.function.name);
- return '';
-}
-
-function pruneInvalidChatToolChoice(chatBody) {
- if (!isRecord(chatBody) || !Array.isArray(chatBody.tools)) return;
- if (chatBody.tools.length === 0) {
- delete chatBody.tools;
- delete chatBody.tool_choice;
- return;
- }
- const chosenName = getChatToolChoiceName(chatBody.tool_choice);
- if (!chosenName) return;
- const toolNames = new Set(chatBody.tools
- .map((tool) => isRecord(tool) && isRecord(tool.function) ? asTrimmedString(tool.function.name) : '')
- .filter(Boolean));
- if (!toolNames.has(chosenName)) {
- delete chatBody.tool_choice;
- }
-}
-
-function normalizeResponsesToolsForResponsesApi(tools) {
- if (!Array.isArray(tools)) return tools;
- return tools
- .map((tool) => {
- const converted = normalizeFunctionToolForChat(tool);
- if (!converted || !converted.function) return null;
- const out = {
- type: 'function',
- name: converted.function.name
- };
- if (converted.function.description !== undefined) out.description = converted.function.description;
- if (converted.function.parameters !== undefined) out.parameters = converted.function.parameters;
- if (converted.function.strict !== undefined) out.strict = converted.function.strict;
- return out;
- })
- .filter(Boolean);
-}
-
-function mergeLeadingSystemMessages(messages, leadingInstructions) {
- const segments = [];
- const seen = new Set();
- const pushSegment = (text) => {
- const trimmed = typeof text === 'string' ? text.trim() : '';
- if (!trimmed || seen.has(trimmed)) return;
- seen.add(trimmed);
- segments.push(trimmed);
- };
- if (typeof leadingInstructions === 'string') {
- pushSegment(leadingInstructions);
- }
- const rest = [];
- for (const msg of messages) {
- if (msg && msg.role === 'system') {
- const content = msg.content;
- if (typeof content === 'string') {
- pushSegment(content);
- } else if (Array.isArray(content)) {
- for (const part of content) {
- if (part && typeof part === 'object' && typeof part.text === 'string') {
- pushSegment(part.text);
- }
- }
- }
- continue;
- }
- rest.push(msg);
- }
- const out = [];
- if (segments.length) {
- out.push({ role: 'system', content: segments.join('\n\n---\n\n') });
- }
- for (const msg of rest) out.push(msg);
- return out;
-}
-
-function messageContentAsText(content) {
- if (typeof content === 'string') return content;
- if (!Array.isArray(content)) return '';
- return content
- .map((item) => {
- if (typeof item === 'string') return item;
- if (!isRecord(item)) return '';
- if (typeof item.text === 'string') return item.text;
- if (typeof item.content === 'string') return item.content;
- return '';
- })
- .filter(Boolean)
- .join('\n');
-}
-
-function hasRunningCodexExecSession(messages) {
- if (!Array.isArray(messages)) return false;
- return messages.some((message) => {
- if (!isRecord(message) || message.role !== 'tool') return false;
- return /Process running with session ID\s+\d+/i.test(messageContentAsText(message.content));
- });
-}
-
-function appendChatFallbackRuntimeInstructions(baseInstructions, rawMessages) {
- const segments = [];
- const base = typeof baseInstructions === 'string' ? baseInstructions.trim() : '';
- if (base) segments.push(base);
- if (hasRunningCodexExecSession(rawMessages)) {
- segments.push('Codex tool output indicates a command is still running ("Process running with session ID ..."). You must call write_stdin with that numeric session_id and empty chars to poll/wait for completion before giving a final answer. Do not merely say that you are waiting.');
- }
- return segments.join('\n\n');
-}
-
-function convertResponsesRequestToChatCompletions(payload) {
- const body = payload && typeof payload === 'object' ? payload : {};
- const model = typeof body.model === 'string' ? body.model.trim() : '';
- if (!model) {
- return { error: 'responses 请求缺少 model' };
- }
-
- const rawMessages = normalizeResponsesInputToChatMessages(body.input);
- const leadingInstructions = appendChatFallbackRuntimeInstructions(body.instructions, rawMessages);
- // codex 同时下发 body.instructions(内置 prompt)与 input 内 developer/system 消息(AGENTS.md)。
- // 合流为一条领头 system,避免某些上游"只认第一条 system"导致 AGENTS.md 失效。
- const messages = mergeLeadingSystemMessages(rawMessages, leadingInstructions);
- if (!messages.length) {
- // codex sometimes sends empty input for probes; tolerate.
- messages.push({ role: 'user', content: '' });
- }
-
- const maxOutputTokens = Number.parseInt(String(body.max_output_tokens), 10);
- const stream = body.stream === true;
-
- const chat = {
- model,
- messages,
- stream: false,
- temperature: Number.isFinite(body.temperature) ? Number(body.temperature) : undefined,
- top_p: Number.isFinite(body.top_p) ? Number(body.top_p) : undefined,
- max_tokens: Number.isFinite(maxOutputTokens) && maxOutputTokens > 0 ? maxOutputTokens : undefined
- };
- if (isRecord(body.reasoning)) {
- const effort = asTrimmedString(body.reasoning.effort);
- if (effort) chat.reasoning_effort = effort;
- } else if (asTrimmedString(body.reasoning_effort)) {
- chat.reasoning_effort = asTrimmedString(body.reasoning_effort);
- }
- if (Array.isArray(body.stop) && body.stop.length) {
- chat.stop = body.stop.filter((item) => typeof item === 'string' && item.trim());
- }
- if (Array.isArray(body.tools) && body.tools.length) {
- chat.tools = normalizeResponsesToolsToChatTools(body.tools);
- }
- if (body.tool_choice !== undefined) {
- chat.tool_choice = normalizeResponsesToolChoiceToChatToolChoice(body.tool_choice);
- }
- if (body.response_format !== undefined) {
- chat.response_format = body.response_format;
- }
- if (body.metadata !== undefined) {
- chat.metadata = body.metadata;
- }
-
- pruneInvalidChatToolChoice(chat);
-
- // Remove undefined keys
- Object.keys(chat).forEach((key) => chat[key] === undefined && delete chat[key]);
-
- return { chat, streamRequested: stream, toolTypesByName: collectResponsesToolTypesByName(body.tools) };
-}
-
-function buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamPayload, options = {}) {
- const responseId = `resp_${crypto.randomBytes(10).toString('hex')}`;
- const usage = upstreamPayload && upstreamPayload.usage && typeof upstreamPayload.usage === 'object'
- ? upstreamPayload.usage
- : null;
- const createdAt = Math.floor(Date.now() / 1000);
- const output = [];
- const trimmedText = typeof text === 'string' ? text : '';
- if (trimmedText) {
- output.push({
- id: `msg_${crypto.randomBytes(8).toString('hex')}`,
- type: 'message',
- role: 'assistant',
- content: [{ type: 'output_text', text: trimmedText }]
- });
- }
-
- // Convert chat.completions tool_calls back into the original Responses item type.
- // Treating every call as `function_call` makes Codex built-ins (custom/local_shell)
- // degrade into ordinary chat text instead of executable agent steps.
- if (Array.isArray(toolCalls)) {
- for (const call of toolCalls) {
- const item = buildResponsesToolCallItemFromChatToolCall(call, options.toolTypesByName || {});
- if (item) output.push(item);
- }
- }
-
- const choice = upstreamPayload && typeof upstreamPayload === 'object' && Array.isArray(upstreamPayload.choices)
- ? upstreamPayload.choices[0]
- : null;
- const finishReason = choice && typeof choice.finish_reason === 'string' ? choice.finish_reason : '';
- const statusPatch = finishReason === 'length'
- ? { status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } }
- : (finishReason === 'content_filter'
- ? { status: 'incomplete', incomplete_details: { reason: 'content_filter' } }
- : { status: 'completed' });
-
- const payload = {
- id: responseId,
- object: 'response',
- model,
- created_at: createdAt,
- ...statusPatch,
- output,
- output_text: trimmedText
- };
-
- if (usage) {
- // Map chat.completions usage -> responses usage shape when possible.
- const promptTokens = Number.isFinite(usage.prompt_tokens) ? Number(usage.prompt_tokens) : null;
- const completionTokens = Number.isFinite(usage.completion_tokens) ? Number(usage.completion_tokens) : null;
- const totalTokens = Number.isFinite(usage.total_tokens) ? Number(usage.total_tokens) : null;
- if (promptTokens !== null || completionTokens !== null || totalTokens !== null) {
- payload.usage = {
- input_tokens: promptTokens ?? undefined,
- output_tokens: completionTokens ?? undefined,
- total_tokens: totalTokens ?? undefined
- };
- Object.keys(payload.usage).forEach((key) => payload.usage[key] === undefined && delete payload.usage[key]);
- } else {
- payload.usage = usage;
- }
- }
-
- return payload;
-}
-
-function ensureResponseMetadata(response) {
- const payload = response && typeof response === 'object' ? response : {};
- if (typeof payload.object !== 'string' || !payload.object.trim()) {
- payload.object = 'response';
- }
- if (typeof payload.created_at !== 'number') {
- payload.created_at = Math.floor(Date.now() / 1000);
- }
- if (typeof payload.status !== 'string' || !payload.status.trim()) {
- payload.status = 'completed';
- }
- if (!Array.isArray(payload.output)) {
- payload.output = [];
- }
- return payload;
-}
-
-function sendResponsesSse(res, responsePayload) {
- const response = ensureResponseMetadata(responsePayload);
- const responseId = typeof response.id === 'string' && response.id.trim()
- ? response.id.trim()
- : `resp_${crypto.randomBytes(10).toString('hex')}`;
- const model = typeof response.model === 'string' ? response.model : '';
-
- let sequence = 0;
- const nextSeq = () => {
- sequence += 1;
- return sequence;
- };
-
- writeSse(res, 'response.created', {
- type: 'response.created',
- response: {
- id: responseId,
- model,
- created_at: response.created_at
- }
- });
-
- const output = Array.isArray(response.output) ? response.output : [];
- for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
- const item = output[outputIndex];
- if (!item || typeof item !== 'object') continue;
- const itemType = typeof item.type === 'string' ? item.type : '';
- const itemId = typeof item.id === 'string' && item.id.trim()
- ? item.id.trim()
- : (typeof item.call_id === 'string' && item.call_id.trim() ? item.call_id.trim() : `item_${crypto.randomBytes(8).toString('hex')}`);
- const wireItem = buildResponsesSseItem(item, itemId);
-
- // Emit item added so Codex can anchor subsequent deltas by output_index/content_index/item_id.
- writeSse(res, 'response.output_item.added', {
- type: 'response.output_item.added',
- output_index: outputIndex,
- item: wireItem,
- sequence_number: nextSeq()
- });
-
- if (itemType === 'message') {
- const content = Array.isArray(item.content) ? item.content : [];
- for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
- const block = content[contentIndex];
- if (!block || typeof block !== 'object') continue;
- if (block.type !== 'output_text') continue;
- const text = typeof block.text === 'string' ? block.text : '';
- emitResponsesTextPartAdded(res, itemId, outputIndex, contentIndex, nextSeq);
- if (text) {
- writeSse(res, 'response.output_text.delta', {
- type: 'response.output_text.delta',
- item_id: itemId,
- output_index: outputIndex,
- content_index: contentIndex,
- delta: text,
- sequence_number: nextSeq()
- });
- }
- writeSse(res, 'response.output_text.done', {
- type: 'response.output_text.done',
- item_id: itemId,
- output_index: outputIndex,
- content_index: contentIndex,
- text,
- sequence_number: nextSeq()
- });
- emitResponsesTextPartDone(res, itemId, outputIndex, contentIndex, text, nextSeq);
- }
- } else if (itemType === 'function_call' || itemType === 'custom_tool_call') {
- emitResponsesToolArgumentEvents(res, wireItem, outputIndex, nextSeq);
- } else if (itemType === 'reasoning') {
- const summary = Array.isArray(item.summary) ? item.summary : [];
- for (let summaryIndex = 0; summaryIndex < summary.length; summaryIndex += 1) {
- const block = summary[summaryIndex];
- const text = block && typeof block.text === 'string' ? block.text : '';
- if (text) {
- writeSse(res, 'response.reasoning_summary_text.delta', {
- type: 'response.reasoning_summary_text.delta',
- item_id: itemId,
- output_index: outputIndex,
- summary_index: summaryIndex,
- delta: text,
- sequence_number: nextSeq()
- });
- }
- writeSse(res, 'response.reasoning_summary_text.done', {
- type: 'response.reasoning_summary_text.done',
- item_id: itemId,
- output_index: outputIndex,
- summary_index: summaryIndex,
- text,
- sequence_number: nextSeq()
- });
- }
- }
-
- // Emit item done for all item types (message/function_call/etc).
- writeSse(res, 'response.output_item.done', {
- type: 'response.output_item.done',
- output_index: outputIndex,
- item: wireItem,
- sequence_number: nextSeq()
- });
- }
-
- writeSse(res, 'response.completed', { type: 'response.completed', response });
- writeSse(res, 'done', '[DONE]');
- }
-
-function extractResponsesOutputText(payload) {
- if (!payload || typeof payload !== 'object') return '';
- const output = Array.isArray(payload.output) ? payload.output : [];
- for (const item of output) {
- if (!item || typeof item !== 'object') continue;
- if (item.type !== 'message') continue;
- const content = Array.isArray(item.content) ? item.content : [];
- for (const block of content) {
- if (!block || typeof block !== 'object') continue;
- if (block.type !== 'output_text') continue;
- if (typeof block.text === 'string') return block.text;
- }
- }
- if (typeof payload.output_text === 'string') return payload.output_text;
- return '';
-}
-
-function normalizeResponsesPayloadForUpstream(payload, stream) {
- const body = payload && typeof payload === 'object' ? payload : {};
- const normalized = { ...body, stream };
- if (Array.isArray(body.tools)) {
- normalized.tools = normalizeResponsesToolsForResponsesApi(body.tools);
- }
- if (isRecord(body.reasoning)) {
- const include = Array.isArray(body.include) ? body.include.filter((item) => typeof item === 'string') : [];
- if (!include.includes('reasoning.encrypted_content')) {
- normalized.include = [...include, 'reasoning.encrypted_content'];
- }
- }
- return normalized;
-}
-
-function toUpstreamNonStreamingResponsesPayload(payload) {
- return normalizeResponsesPayloadForUpstream(payload, false);
-}
-
-function shouldFallbackFromUpstreamResponses(status, bodyText) {
- if (!Number.isFinite(status)) return false;
- // Common "unsupported" status codes for a route.
- if (status === 404 || status === 405 || status === 501) return true;
-
- // Some OpenAI-compatible gateways respond with 500 + "not implemented" (e.g. convert_request_failed)
- // instead of 404/405 for unsupported endpoints. In that case we can safely fallback to chat/completions.
- const text = String(bodyText || '');
- if (!text) return false;
- if (/not implemented/i.test(text)) return true;
- if (/convert_request_failed/i.test(text)) return true;
- if (/unknown (endpoint|route)/i.test(text)) return true;
- if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
- if (/does not support.*responses/i.test(text)) return true;
- if (/name['"`]?\s+is a required property/i.test(text) && /tools/i.test(text) && /function/i.test(text)) return true;
-
- // Best-effort parse for structured error codes.
- try {
- const parsed = JSON.parse(text);
- const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
- const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
- if (code === 'convert_request_failed') return true;
- if (/not implemented/i.test(msg)) return true;
- if (/unknown (endpoint|route)/i.test(msg)) return true;
- if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
- if (/does not support.*responses/i.test(msg)) return true;
- if (/name['"`]?\s+is a required property/i.test(msg) && /tools/i.test(msg) && /function/i.test(msg)) return true;
- } catch (_) {}
-
- return false;
-}
-
-// 仅识别"端点级别不支持"——可缓存,与 per-request 的 tool 格式错误区分。
-function isResponsesEndpointUnsupported(status, bodyText) {
- if (!Number.isFinite(status)) return false;
- if (status === 404 || status === 405 || status === 501) return true;
- const text = String(bodyText || '');
- if (!text) return false;
- if (/not implemented/i.test(text)) return true;
- if (/convert_request_failed/i.test(text)) return true;
- if (/unknown (endpoint|route)/i.test(text)) return true;
- if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
- if (/does not support.*responses/i.test(text)) return true;
- try {
- const parsed = JSON.parse(text);
- const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
- const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
- if (code === 'convert_request_failed') return true;
- if (/not implemented/i.test(msg)) return true;
- if (/unknown (endpoint|route)/i.test(msg)) return true;
- if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
- if (/does not support.*responses/i.test(msg)) return true;
- } catch (_) {}
- return false;
-}
-
-function isLoopbackAddress(address) {
- if (!address) return false;
- const value = String(address);
- return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
-}
-
-function isTransientNetworkError(error) {
- const text = String(error || '').trim();
- if (!text) return false;
- if (/socket hang up/i.test(text)) return true;
- if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
- if (/EAI_AGAIN/i.test(text)) return true;
- if (/UND_ERR_SOCKET/i.test(text)) return true;
- if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
- return false;
-}
-
-const TRANSIENT_RETRY_DELAYS_MS = [200, 600];
-
-async function retryTransientRequest(executor) {
- let lastResult = null;
- for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) {
- if (attempt > 0) {
- const delay = TRANSIENT_RETRY_DELAYS_MS[attempt - 1];
- // eslint-disable-next-line no-await-in-loop
- await new Promise((r) => {
- const t = setTimeout(r, delay);
- if (typeof t.unref === 'function') t.unref();
- });
- }
- // eslint-disable-next-line no-await-in-loop
- const result = await executor(attempt);
- lastResult = result;
- if (!result) return result;
- if (result.ok) return result;
- if (result.retry) return result;
- if (result.status && result.status > 0) return result;
- if (!isTransientNetworkError(result.error)) return result;
- }
- return lastResult;
-}
-
-function writeSse(res, eventName, dataObj) {
- if (!res || res.writableEnded || res.destroyed) return;
- if (eventName) {
- res.write(`event: ${eventName}\n`);
- }
- if (dataObj === '[DONE]') {
- res.write('data: [DONE]\n\n');
- return;
- }
- res.write(`data: ${JSON.stringify(dataObj)}\n\n`);
-}
-
-function buildResponsesSseItem(item, fallbackId) {
- const source = isRecord(item) ? item : {};
- const itemId = asTrimmedString(source.id || source.call_id) || fallbackId || `item_${crypto.randomBytes(8).toString('hex')}`;
- if (source.type === 'message') {
- return {
- ...source,
- id: itemId,
- role: source.role || 'assistant',
- content: Array.isArray(source.content) ? source.content : []
- };
- }
- if (source.type === 'reasoning') {
- return {
- ...source,
- id: itemId,
- summary: Array.isArray(source.summary) ? source.summary : []
- };
- }
- if (source.type === 'function_call') {
- return {
- ...source,
- id: itemId,
- call_id: asTrimmedString(source.call_id) || itemId,
- name: asTrimmedString(source.name),
- arguments: typeof source.arguments === 'string' ? source.arguments : ''
- };
- }
- if (source.type === 'custom_tool_call') {
- return {
- ...source,
- id: itemId,
- call_id: asTrimmedString(source.call_id) || itemId,
- name: asTrimmedString(source.name),
- input: typeof source.input === 'string' ? source.input : ''
- };
- }
- return { ...source, id: itemId };
-}
-
-function emitResponsesTextPartAdded(res, itemId, outputIndex, contentIndex, nextSeq) {
- writeSse(res, 'response.content_part.added', {
- type: 'response.content_part.added',
- item_id: itemId,
- output_index: outputIndex,
- content_index: contentIndex,
- part: { type: 'output_text', text: '', annotations: [], logprobs: [] },
- sequence_number: nextSeq()
- });
-}
-
-function emitResponsesTextPartDone(res, itemId, outputIndex, contentIndex, text, nextSeq) {
- writeSse(res, 'response.content_part.done', {
- type: 'response.content_part.done',
- item_id: itemId,
- output_index: outputIndex,
- content_index: contentIndex,
- part: { type: 'output_text', text: typeof text === 'string' ? text : '', annotations: [], logprobs: [] },
- sequence_number: nextSeq()
- });
-}
-
-function emitResponsesToolArgumentEvents(res, item, outputIndex, nextSeq) {
- const eventType = item.type === 'custom_tool_call'
- ? 'response.custom_tool_call_input.delta'
- : 'response.function_call_arguments.delta';
- const doneType = item.type === 'custom_tool_call'
- ? ''
- : 'response.function_call_arguments.done';
- const value = item.type === 'custom_tool_call'
- ? (typeof item.input === 'string' ? item.input : '')
- : (typeof item.arguments === 'string' ? item.arguments : '');
- if (value) {
- writeSse(res, eventType, {
- type: eventType,
- item_id: item.id,
- output_index: outputIndex,
- call_id: item.call_id,
- name: item.name,
- delta: value,
- sequence_number: nextSeq()
- });
- }
- if (doneType) {
- writeSse(res, doneType, {
- type: doneType,
- item_id: item.id,
- output_index: outputIndex,
- call_id: item.call_id,
- name: item.name,
- arguments: value,
- sequence_number: nextSeq()
- });
- }
-}
-
-function appendChatStreamToolCall(target, toolCall) {
- if (!toolCall || typeof toolCall !== 'object') return;
- const index = Number.isFinite(toolCall.index) ? toolCall.index : target.length;
- if (!target[index]) {
- target[index] = {
- id: '',
- type: 'function',
- function: { name: '', arguments: '' }
- };
- }
- const current = target[index];
- if (typeof toolCall.id === 'string' && toolCall.id) current.id = toolCall.id;
- if (typeof toolCall.type === 'string' && toolCall.type) current.type = toolCall.type;
- const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : null;
- if (fn) {
- if (typeof fn.name === 'string' && fn.name) current.function.name = fn.name;
- if (typeof fn.arguments === 'string') current.function.arguments += fn.arguments;
- }
-}
-
-function writeChatCompletionChunkAsResponsesSse(state, chunk) {
- if (!chunk || typeof chunk !== 'object') return;
- if (typeof chunk.model === 'string' && chunk.model) {
- state.model = chunk.model;
- }
- const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
- for (const choice of choices) {
- const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
- if (!delta) continue;
-
- const reasoningSegment = typeof delta.reasoning_content === 'string'
- ? delta.reasoning_content
- : (typeof delta.reasoning === 'string'
- ? delta.reasoning
- : (typeof delta.reasoning_text === 'string' ? delta.reasoning_text : ''));
- if (reasoningSegment) {
- if (!state.reasoningItem) {
- state.reasoningItem = {
- id: `rs_${crypto.randomBytes(8).toString('hex')}`,
- type: 'reasoning',
- summary: [{ type: 'summary_text', text: '' }]
- };
- state.output.push(state.reasoningItem);
- writeSse(state.res, 'response.output_item.added', {
- type: 'response.output_item.added',
- output_index: state.output.length - 1,
- item: buildResponsesSseItem(state.reasoningItem, state.reasoningItem.id)
- });
- }
- state.reasoningText += reasoningSegment;
- state.reasoningItem.summary[0].text = state.reasoningText;
- writeSse(state.res, 'response.reasoning_summary_text.delta', {
- type: 'response.reasoning_summary_text.delta',
- item_id: state.reasoningItem.id,
- output_index: state.output.indexOf(state.reasoningItem),
- summary_index: 0,
- delta: reasoningSegment,
- sequence_number: state.nextSeq()
- });
- }
-
- const segments = [];
- // DeepSeek-style OpenAI-compatible streams may emit private reasoning in
- // `reasoning_content` before the final answer. Responses `output_text`
- // must stay user-visible answer text only; forwarding reasoning here
- // pollutes Codex output and breaks exact-answer prompts.
- if (typeof delta.content === 'string' && delta.content) {
- segments.push(delta.content);
- }
- for (const seg of segments) {
- if (!state.messageItem) {
- state.messageItem = {
- id: `msg_${crypto.randomBytes(8).toString('hex')}`,
- type: 'message',
- role: 'assistant',
- content: [{ type: 'output_text', text: '' }]
- };
- state.output.push(state.messageItem);
- writeSse(state.res, 'response.output_item.added', {
- type: 'response.output_item.added',
- output_index: state.output.length - 1,
- item: buildResponsesSseItem(state.messageItem, state.messageItem.id)
- });
- emitResponsesTextPartAdded(state.res, state.messageItem.id, state.output.length - 1, 0, state.nextSeq);
- }
- state.messageText += seg;
- state.messageItem.content[0].text = state.messageText;
- writeSse(state.res, 'response.output_text.delta', {
- type: 'response.output_text.delta',
- item_id: state.messageItem.id,
- output_index: state.output.length - 1,
- content_index: 0,
- delta: seg,
- sequence_number: state.nextSeq()
- });
- }
-
- if (Array.isArray(delta.tool_calls)) {
- for (const toolCall of delta.tool_calls) {
- appendChatStreamToolCall(state.toolCalls, toolCall);
- }
- }
-
- if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
- state.sawFinishReason = true;
- }
- }
-}
-
-function finishChatStreamResponsesSse(state) {
- if (!state || state.finished) return;
- state.finished = true;
-
- if (state.reasoningItem) {
- const outputIndex = state.output.indexOf(state.reasoningItem);
- writeSse(state.res, 'response.reasoning_summary_text.done', {
- type: 'response.reasoning_summary_text.done',
- item_id: state.reasoningItem.id,
- output_index: outputIndex,
- summary_index: 0,
- text: state.reasoningText,
- sequence_number: state.nextSeq()
- });
- writeSse(state.res, 'response.output_item.done', {
- type: 'response.output_item.done',
- output_index: outputIndex,
- item: buildResponsesSseItem(state.reasoningItem, state.reasoningItem.id),
- sequence_number: state.nextSeq()
- });
- }
-
- if (state.messageItem) {
- const outputIndex = state.output.indexOf(state.messageItem);
- writeSse(state.res, 'response.output_text.done', {
- type: 'response.output_text.done',
- item_id: state.messageItem.id,
- output_index: outputIndex,
- content_index: 0,
- text: state.messageText,
- sequence_number: state.nextSeq()
- });
- emitResponsesTextPartDone(state.res, state.messageItem.id, outputIndex, 0, state.messageText, state.nextSeq);
- writeSse(state.res, 'response.output_item.done', {
- type: 'response.output_item.done',
- output_index: outputIndex,
- item: buildResponsesSseItem(state.messageItem, state.messageItem.id),
- sequence_number: state.nextSeq()
- });
- }
-
- for (const toolCall of state.toolCalls) {
- if (!toolCall) continue;
- const item = buildResponsesToolCallItemFromChatToolCall(toolCall, state.toolTypesByName || {});
- if (!item) continue;
- const outputIndex = state.output.length;
- state.output.push(item);
- writeSse(state.res, 'response.output_item.added', {
- type: 'response.output_item.added',
- output_index: outputIndex,
- item: buildResponsesSseItem(item, item.call_id)
- });
- emitResponsesToolArgumentEvents(state.res, buildResponsesSseItem(item, item.call_id), outputIndex, state.nextSeq);
- writeSse(state.res, 'response.output_item.done', {
- type: 'response.output_item.done',
- output_index: outputIndex,
- item: buildResponsesSseItem(item, item.call_id),
- sequence_number: state.nextSeq()
- });
- }
-
- const response = ensureResponseMetadata({
- id: state.responseId,
- model: state.model,
- created_at: state.createdAt,
- status: 'completed',
- output: state.output,
- output_text: state.messageText
- });
- writeSse(state.res, 'response.completed', { type: 'response.completed', response });
- writeSse(state.res, 'done', '[DONE]');
- if (!state.res.writableEnded && !state.res.destroyed) {
- state.res.end();
- }
-}
-
-function failChatStreamResponsesSse(state, errorMessage) {
- if (!state || state.finished) return;
- state.finished = true;
- writeSse(state.res, 'response.failed', {
- type: 'response.failed',
- response: ensureResponseMetadata({
- id: state.responseId,
- model: state.model,
- created_at: state.createdAt,
- status: 'failed',
- output: state.output,
- output_text: state.messageText
- }),
- error: String(errorMessage || 'upstream stream failed')
- });
- writeSse(state.res, 'done', '[DONE]');
- if (!state.res.writableEnded && !state.res.destroyed) {
- state.res.end();
- }
-}
-
-function formatUpstreamStreamError(errorValue) {
- if (!errorValue) return 'upstream stream failed';
- if (typeof errorValue === 'string') return errorValue;
- if (typeof errorValue === 'object') {
- if (typeof errorValue.message === 'string' && errorValue.message) return errorValue.message;
- try { return JSON.stringify(errorValue); } catch (_) {}
- }
- return String(errorValue || 'upstream stream failed');
-}
-
-function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
- const parsed = new URL(targetUrl);
- const transport = parsed.protocol === 'https:' ? https : http;
- const bodyText = options.body ? JSON.stringify(options.body) : '';
- const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
- ? Math.floor(options.maxBytes)
- : 0;
- const headers = {
- 'Accept': 'text/event-stream',
- ...(options.body ? { 'Content-Type': 'application/json' } : {}),
- ...(options.headers || {})
- };
- if (options.body) {
- headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
- }
- const timeoutMs = Number.isFinite(options.timeoutMs)
- ? Math.max(1000, Number(options.timeoutMs))
- : STREAM_IDLE_TIMEOUT_MS;
- const res = options.res;
- const fallbackModel = typeof options.model === 'string' ? options.model : '';
-
- return new Promise((resolve) => {
- let settled = false;
- let upstreamReq = null;
- const finish = (value) => {
- if (settled) return;
- settled = true;
- resolve(value);
- };
- const abortUpstream = () => {
- if (upstreamReq) {
- try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {}
- }
- };
- if (res && typeof res.once === 'function') {
- res.once('close', abortUpstream);
- }
-
- upstreamReq = transport.request({
- protocol: parsed.protocol,
- hostname: parsed.hostname,
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
- method: options.method || 'POST',
- path: `${parsed.pathname}${parsed.search}`,
- headers,
- agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
- }, (upstreamRes) => {
- const status = upstreamRes.statusCode || 0;
- const chunks = [];
- let size = 0;
- const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
-
- const collectChunk = (chunk) => {
- if (!chunk) return true;
- if (maxBytes > 0) {
- size += chunk.length;
- if (size > maxBytes) {
- chunks.length = 0;
- try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
- try { upstreamReq.destroy(new Error('response too large')); } catch (_) {}
- finish({ ok: false, status, error: 'response too large' });
- return false;
- }
- }
- chunks.push(chunk);
- return true;
- };
-
- if (status >= 400) {
- upstreamRes.on('data', collectChunk);
- upstreamRes.on('end', () => finish({
- ok: false,
- status,
- bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
- }));
- return;
- }
-
- if (!res.headersSent) {
- res.writeHead(200, {
- 'Content-Type': 'text/event-stream; charset=utf-8',
- 'Cache-Control': 'no-cache',
- 'Connection': 'keep-alive',
- 'X-Accel-Buffering': 'no'
- });
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
- }
-
- if (!/text\/event-stream/i.test(contentType)) {
- upstreamRes.on('data', collectChunk);
- upstreamRes.on('end', () => {
- const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
- const parsedJson = parseJsonOrError(text);
- if (parsedJson.error) {
- writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
- writeSse(res, 'done', '[DONE]');
- if (!res.writableEnded && !res.destroyed) res.end();
- finish({ ok: true });
- return;
- }
- const extracted = extractChatCompletionResult(parsedJson.value);
- sendResponsesSse(res, buildResponsesPayloadFromChatResult(fallbackModel, extracted.text, extracted.toolCalls, parsedJson.value, {
- toolTypesByName: options.toolTypesByName || {}
- }));
- if (!res.writableEnded && !res.destroyed) res.end();
- finish({ ok: true });
- });
- return;
- }
-
- let sequence = 0;
- const state = {
- res,
- responseId: `resp_${crypto.randomBytes(10).toString('hex')}`,
- model: fallbackModel,
- createdAt: Math.floor(Date.now() / 1000),
- output: [],
- messageItem: null,
- messageText: '',
- reasoningItem: null,
- reasoningText: '',
- toolCalls: [],
- toolTypesByName: options.toolTypesByName || {},
- finished: false,
- sawDone: false,
- sawFinishReason: false,
- nextSeq: () => {
- sequence += 1;
- return sequence;
- }
- };
- writeSse(res, 'response.created', {
- type: 'response.created',
- response: {
- id: state.responseId,
- model: state.model,
- created_at: state.createdAt
- }
- });
-
- let buffer = '';
- const utf8Decoder = new StringDecoder('utf8');
- const handleEventBlock = (block) => {
- const dataLines = String(block || '')
- .split(/\r?\n/)
- .filter((line) => line.startsWith('data:'))
- .map((line) => line.slice(5).trimStart());
- if (dataLines.length === 0) return;
- const data = dataLines.join('\n').trim();
- if (!data) return;
- if (data === '[DONE]') {
- state.sawDone = true;
- finishChatStreamResponsesSse(state);
- finish({ ok: true });
- return;
- }
- const parsedChunk = parseJsonOrError(data);
- if (!parsedChunk.error) {
- if (parsedChunk.value && typeof parsedChunk.value === 'object' && parsedChunk.value.error) {
- failChatStreamResponsesSse(state, formatUpstreamStreamError(parsedChunk.value.error));
- finish({ ok: true });
- return;
- }
- writeChatCompletionChunkAsResponsesSse(state, parsedChunk.value);
- }
- };
-
- upstreamRes.on('data', (chunk) => {
- if (!chunk) return;
- buffer += utf8Decoder.write(chunk);
- let boundary = buffer.search(/\r?\n\r?\n/);
- while (boundary >= 0) {
- const block = buffer.slice(0, boundary);
- const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
- buffer = buffer.slice(boundary + (match ? match[0].length : 2));
- handleEventBlock(block);
- boundary = buffer.search(/\r?\n\r?\n/);
- }
- });
- upstreamRes.on('end', () => {
- buffer += utf8Decoder.end();
- if (buffer.trim()) handleEventBlock(buffer);
- if (!state.finished && !state.sawDone && !state.sawFinishReason) {
- failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]');
- finish({ ok: true });
- return;
- }
- finishChatStreamResponsesSse(state);
- finish({ ok: true });
- });
- upstreamRes.on('aborted', () => {
- failChatStreamResponsesSse(state, 'upstream stream aborted');
- finish({ ok: true });
- });
- upstreamRes.on('error', (err) => {
- failChatStreamResponsesSse(state, err && err.message ? err.message : 'upstream stream failed');
- finish({ ok: true });
- });
- });
- upstreamReq.setTimeout(timeoutMs, () => {
- try { upstreamReq.destroy(new Error('timeout')); } catch (_) {}
- finish({ ok: false, error: 'timeout' });
- });
- upstreamReq.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
- if (bodyText) upstreamReq.write(bodyText);
- upstreamReq.end();
- });
-}
-
-function streamResponsesSse(targetUrl, options = {}) {
- const parsed = new URL(targetUrl);
- const transport = parsed.protocol === 'https:' ? https : http;
- const bodyText = options.body ? JSON.stringify(options.body) : '';
- const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
- ? Math.floor(options.maxBytes)
- : 0;
- const headers = {
- 'Accept': 'text/event-stream',
- ...(options.body ? { 'Content-Type': 'application/json' } : {}),
- ...(options.headers || {})
- };
- if (options.body) {
- headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
- }
- const timeoutMs = Number.isFinite(options.timeoutMs)
- ? Math.max(1000, Number(options.timeoutMs))
- : STREAM_IDLE_TIMEOUT_MS;
- const res = options.res;
-
- return new Promise((resolve) => {
- let settled = false;
- let upstreamReq = null;
- let streamAccepted = false;
- let streamIdleTimer = null;
- const finish = (value) => {
- if (settled) return;
- settled = true;
- if (streamIdleTimer) {
- clearTimeout(streamIdleTimer);
- streamIdleTimer = null;
- }
- resolve(value);
- };
- const abortUpstream = () => {
- if (upstreamReq) {
- try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {}
- }
- };
- if (res && typeof res.once === 'function') {
- res.once('close', abortUpstream);
- }
-
- upstreamReq = transport.request({
- protocol: parsed.protocol,
- hostname: parsed.hostname,
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
- method: options.method || 'POST',
- path: `${parsed.pathname}${parsed.search}`,
- headers,
- agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
- }, (upstreamRes) => {
- const status = upstreamRes.statusCode || 0;
- const chunks = [];
- let size = 0;
- const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
- const collectChunk = (chunk) => {
- if (!chunk) return true;
- if (maxBytes > 0) {
- size += chunk.length;
- if (size > maxBytes) {
- chunks.length = 0;
- try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
- try { upstreamReq.destroy(new Error('response too large')); } catch (_) {}
- finish({ ok: false, status, error: 'response too large' });
- return false;
- }
- }
- chunks.push(chunk);
- return true;
- };
- const bodyFromChunks = () => (chunks.length ? Buffer.concat(chunks).toString('utf-8') : '');
-
- if (status === 404 || status === 405) {
- upstreamRes.on('data', collectChunk);
- upstreamRes.on('end', () => finish({ retry: true, status, bodyText: bodyFromChunks() }));
- return;
- }
-
- if (status >= 400) {
- upstreamRes.on('data', collectChunk);
- upstreamRes.on('end', () => finish({ ok: false, status, bodyText: bodyFromChunks() }));
- return;
- }
-
- if (/text\/event-stream/i.test(contentType)) {
- streamAccepted = true;
- upstreamReq.setTimeout(0);
- const failAcceptedStream = (message) => {
- if (!res.writableEnded && !res.destroyed) {
- writeSse(res, 'response.failed', { type: 'response.failed', error: message });
- writeSse(res, 'done', '[DONE]');
- res.end();
- }
- try { upstreamRes.destroy(new Error(message)); } catch (_) {}
- try { upstreamReq.destroy(new Error(message)); } catch (_) {}
- finish({ ok: true });
- };
- const resetStreamIdleTimer = () => {
- if (streamIdleTimer) clearTimeout(streamIdleTimer);
- streamIdleTimer = setTimeout(() => {
- failAcceptedStream('upstream stream idle timeout');
- }, timeoutMs);
- if (typeof streamIdleTimer.unref === 'function') streamIdleTimer.unref();
- };
- if (!res.headersSent) {
- res.writeHead(200, {
- 'Content-Type': 'text/event-stream; charset=utf-8',
- 'Cache-Control': 'no-cache',
- 'Connection': 'keep-alive',
- 'X-Accel-Buffering': 'no'
- });
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
- }
- resetStreamIdleTimer();
- upstreamRes.on('data', (chunk) => {
- resetStreamIdleTimer();
- if (chunk && !res.writableEnded && !res.destroyed) res.write(chunk);
- });
- upstreamRes.on('end', () => {
- if (!res.writableEnded && !res.destroyed) res.end();
- finish({ ok: true });
- });
- upstreamRes.on('aborted', () => {
- if (!res.writableEnded && !res.destroyed) {
- writeSse(res, 'response.failed', { type: 'response.failed', error: 'upstream stream aborted' });
- writeSse(res, 'done', '[DONE]');
- res.end();
- }
- finish({ ok: true });
- });
- upstreamRes.on('error', (err) => {
- if (!res.writableEnded && !res.destroyed) {
- writeSse(res, 'response.failed', { type: 'response.failed', error: err && err.message ? err.message : 'upstream stream failed' });
- writeSse(res, 'done', '[DONE]');
- res.end();
- }
- finish({ ok: true });
- });
- return;
- }
-
- upstreamRes.on('data', collectChunk);
- upstreamRes.on('end', () => {
- const text = bodyFromChunks();
- const parsedJson = parseJsonOrError(text);
- if (!res.headersSent) {
- res.writeHead(200, {
- 'Content-Type': 'text/event-stream; charset=utf-8',
- 'Cache-Control': 'no-cache',
- 'Connection': 'keep-alive',
- 'X-Accel-Buffering': 'no'
- });
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
- }
- if (parsedJson.error) {
- writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
- writeSse(res, 'done', '[DONE]');
- if (!res.writableEnded && !res.destroyed) res.end();
- finish({ ok: true });
- return;
- }
- sendResponsesSse(res, ensureResponseMetadata(parsedJson.value));
- if (!res.writableEnded && !res.destroyed) res.end();
- finish({ ok: true });
- });
- });
- upstreamReq.setTimeout(timeoutMs, () => {
- if (streamAccepted) return;
- try { upstreamReq.destroy(new Error('timeout')); } catch (_) {}
- finish({ ok: false, error: 'timeout' });
- });
- upstreamReq.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
- if (bodyText) upstreamReq.write(bodyText);
- upstreamReq.end();
- });
-}
-
-async function proxyRequestJson(targetUrl, options = {}) {
- const parsed = new URL(targetUrl);
- const transport = parsed.protocol === 'https:' ? https : http;
- const bodyText = options.body ? JSON.stringify(options.body) : '';
- const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
- ? Math.floor(options.maxBytes)
- : 0;
- const headers = {
- 'Accept': 'application/json',
- ...(options.body ? { 'Content-Type': 'application/json' } : {}),
- ...(options.headers || {})
- };
- if (options.body) {
- headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
- }
-
- const timeoutMs = Number.isFinite(options.timeoutMs)
- ? Math.max(1000, Number(options.timeoutMs))
- : REQUEST_TIMEOUT_MS;
- return new Promise((resolve) => {
- let settled = false;
- const finish = (value) => {
- if (settled) return;
- settled = true;
- resolve(value);
- };
- const req = transport.request({
- protocol: parsed.protocol,
- hostname: parsed.hostname,
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
- method: options.method || 'GET',
- path: `${parsed.pathname}${parsed.search}`,
- headers,
- agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
- }, (upstreamRes) => {
- const chunks = [];
- let size = 0;
- upstreamRes.on('data', (chunk) => {
- if (!chunk) return;
- if (maxBytes > 0) {
- size += chunk.length;
- if (size > maxBytes) {
- chunks.length = 0;
- try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
- try { req.destroy(new Error('response too large')); } catch (_) {}
- finish({ ok: false, error: 'response too large' });
- return;
- }
- }
- chunks.push(chunk);
- });
- upstreamRes.on('end', () => {
- const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
- finish({
- ok: true,
- status: upstreamRes.statusCode || 0,
- headers: upstreamRes.headers || {},
- bodyText: text
- });
- });
- });
- req.setTimeout(timeoutMs, () => {
- try { req.destroy(new Error('timeout')); } catch (_) {}
- finish({ ok: false, error: 'timeout' });
- });
- req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
- if (bodyText) {
- req.write(bodyText);
- }
- req.end();
- });
-}
-
function createOpenaiBridgeHttpHandler(options = {}) {
const settingsFile = options.settingsFile;
const expectedTokenRaw = typeof options.expectedToken === 'string' ? options.expectedToken.trim() : '';
@@ -2188,7 +384,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
maxBytes: maxUpstreamBytes,
httpAgent,
httpsAgent
- }));
+ }), { maxRetries: upstream.maxRetries });
if (!result.ok) {
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` }));
@@ -2243,7 +439,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
httpAgent,
httpsAgent,
res
- }));
+ }), { maxRetries: upstream.maxRetries });
if (streamedResponses.ok) {
clearResponsesUnsupported(upstream.baseUrl);
@@ -2290,7 +486,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
res,
model: typeof chatBody.model === 'string' ? chatBody.model : '',
toolTypesByName: converted.toolTypesByName || {}
- }));
+ }), { maxRetries: upstream.maxRetries });
if (!streamed.ok) {
if (res.writableEnded || res.destroyed) {
return;
@@ -2321,7 +517,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
maxBytes: maxUpstreamBytes,
httpAgent,
httpsAgent
- }));
+ }), { maxRetries: upstream.maxRetries });
if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) {
clearResponsesUnsupported(upstream.baseUrl);
@@ -2383,7 +579,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
maxBytes: maxUpstreamBytes,
httpAgent,
httpsAgent
- }));
+ }), { maxRetries: upstream.maxRetries });
if (!upstreamResult.ok) {
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));
@@ -2455,6 +651,7 @@ module.exports = {
extractChatCompletionResult,
buildResponsesPayloadFromChatResult,
retryTransientRequest,
+ normalizeBridgeMaxRetries,
normalizeOpenaiUpstreamBaseUrl,
extractResponsesOutputText,
shouldFallbackFromUpstreamResponses,
diff --git a/package-lock.json b/package-lock.json
index 2d6394a5..a6bbe5a6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codexmate",
- "version": "0.0.57",
+ "version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexmate",
- "version": "0.0.57",
+ "version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"@iarna/toml": "^2.2.5",
diff --git a/package.json b/package.json
index c25e9ac0..38cf12e4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexmate",
- "version": "0.0.57",
+ "version": "0.1.0",
"description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
"main": "cli.js",
"bin": {
diff --git a/tests/unit/openai-bridge-upstream-responses.test.mjs b/tests/unit/openai-bridge-upstream-responses.test.mjs
index 1cb93f59..bc74981f 100644
--- a/tests/unit/openai-bridge-upstream-responses.test.mjs
+++ b/tests/unit/openai-bridge-upstream-responses.test.mjs
@@ -92,6 +92,54 @@ test('openai-bridge GET /v1 returns local bridge status without probing upstream
await rm(tmpDir, { recursive: true, force: true });
});
+
+test('openai-bridge honors provider maxRetries for transient upstream failures', async () => {
+ let hitCount = 0;
+ const upstream = http.createServer((req, res) => {
+ if (req.url === '/v1/models' && req.method === 'GET') {
+ hitCount += 1;
+ req.socket.destroy();
+ return;
+ }
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'not found' }));
+ });
+ const { port: upstreamPort } = await listen(upstream);
+
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-'));
+ const settingsFile = path.join(tmpDir, 'bridge.json');
+ await writeFile(settingsFile, JSON.stringify({
+ version: 1,
+ providers: {
+ test: {
+ baseUrl: `http://127.0.0.1:${upstreamPort}/v1`,
+ apiKey: 'sk-upstream',
+ maxRetries: 3
+ }
+ }
+ }), 'utf-8');
+
+ const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' });
+ const bridge = http.createServer((req, res) => {
+ if (!handler(req, res)) {
+ res.statusCode = 404;
+ res.end('not handled');
+ }
+ });
+ const { port: bridgePort } = await listen(bridge);
+
+ const resp = await requestText(`http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/models`, {
+ headers: { Authorization: 'Bearer codexmate' }
+ });
+
+ assert.equal(resp.status, 502);
+ assert.equal(hitCount, 4);
+
+ await bridge.close();
+ await upstream.close();
+ await rm(tmpDir, { recursive: true, force: true });
+});
+
test('openai-bridge keeps streaming Codex requests on upstream Responses before chat fallback', async () => {
let responsesHit = false;
let chatHit = false;
diff --git a/tests/unit/provider-default-names.test.mjs b/tests/unit/provider-default-names.test.mjs
index 621dfc6e..d7c8c88f 100644
--- a/tests/unit/provider-default-names.test.mjs
+++ b/tests/unit/provider-default-names.test.mjs
@@ -38,7 +38,8 @@ test('Codex add-provider modal defaults to an auto-increment numeric provider na
url: '',
key: '',
model: '',
- useTransform: false
+ useTransform: false,
+ openaiBridgeMaxRetries: 2
});
context.providersList.push({ name: '3' });
diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs
index 48cc736b..b7e4ce3f 100644
--- a/tests/unit/provider-share-command.test.mjs
+++ b/tests/unit/provider-share-command.test.mjs
@@ -1468,7 +1468,8 @@ test('buildMcpProviderListPayload keeps regular providers editable', () => {
maskKey: (value) => value ? '***' : '',
isBuiltinManagedProvider: () => false,
isNonDeletableProvider: () => false,
- isNonEditableProvider: () => false
+ isNonEditableProvider: () => false,
+ resolveProviderOpenaiBridgeMaxRetries: () => 2
}
);
diff --git a/tests/unit/provider-switch-regression.test.mjs b/tests/unit/provider-switch-regression.test.mjs
index 19609c82..86577d3b 100644
--- a/tests/unit/provider-switch-regression.test.mjs
+++ b/tests/unit/provider-switch-regression.test.mjs
@@ -270,7 +270,7 @@ test('updateProvider keeps existing key when edit key input is blank', async ()
}
}]);
assert.strictEqual(context.showEditModal, false);
- assert.deepStrictEqual(context.editingProvider, { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false });
+ assert.deepStrictEqual(context.editingProvider, { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false, openaiBridgeMaxRetries: 2 });
// c3c9ee5:不再 loadAll,断言本地 providersList url 已更新。
assert.strictEqual(context.loadAllCalls, 0);
assert.strictEqual(context.providersList[0].url, 'https://api.example.com/v1');
diff --git a/tests/unit/providers-validation.test.mjs b/tests/unit/providers-validation.test.mjs
index 55e100ea..0829dd77 100644
--- a/tests/unit/providers-validation.test.mjs
+++ b/tests/unit/providers-validation.test.mjs
@@ -22,8 +22,8 @@ function createContext(overrides = {}, apiImpl = async () => ({ success: true })
showAddModal: true,
showEditModal: false,
resetConfigLoading: false,
- newProvider: { name: '', url: '', key: '', model: '', useTransform: false },
- editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false },
+ newProvider: { name: '', url: '', key: '', model: '', useTransform: false, openaiBridgeMaxRetries: 2 },
+ editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false, openaiBridgeMaxRetries: 2 },
claudeConfigs: {},
showMessage(text, type) {
messages.push({ text: String(text), type: type || 'info' });
@@ -94,7 +94,7 @@ test('addProvider normalizes trimmed values and submits sanitized payload', asyn
}
}]);
assert.strictEqual(context.showAddModal, false);
- assert.deepStrictEqual(context.newProvider, { name: '1', url: '', key: '', model: '', useTransform: false });
+ assert.deepStrictEqual(context.newProvider, { name: '1', url: '', key: '', model: '', useTransform: false, openaiBridgeMaxRetries: 2 });
// c3c9ee5:增删改不再触发 loadAll,改为本地 providersList 增量更新。
assert.deepStrictEqual(loadAllCalls, []);
assert.ok(
@@ -180,3 +180,34 @@ test('provider validation rejects reserved proxy name on add', () => {
assert.strictEqual(context.providerFieldError('add', 'name'), 'codexmate-proxy 为保留名称,不可手动添加');
assert.strictEqual(context.canSubmitProvider('add'), false);
});
+
+test('transform provider submits configurable retry count with minimum two', async () => {
+ const apiCalls = [];
+ const { context } = createContext({
+ newProvider: {
+ name: 'bridge',
+ url: 'https://api.example.com/v1',
+ key: 'sk-live',
+ model: 'gpt-e2e',
+ useTransform: true,
+ openaiBridgeMaxRetries: 1
+ }
+ }, async (action, params) => {
+ apiCalls.push({ action, params });
+ return { success: true };
+ });
+
+ await context.addProvider();
+
+ assert.deepStrictEqual(apiCalls, [{
+ action: 'add-provider',
+ params: {
+ name: 'bridge',
+ url: 'https://api.example.com/v1',
+ key: 'sk-live',
+ model: 'gpt-e2e',
+ useTransform: true,
+ openaiBridgeMaxRetries: 2
+ }
+ }]);
+});
diff --git a/web-ui/app.js b/web-ui/app.js
index 1b73c50b..1889d1ac 100644
--- a/web-ui/app.js
+++ b/web-ui/app.js
@@ -284,9 +284,9 @@ document.addEventListener('DOMContentLoaded', () => {
appVersionStatusChecked: false,
appVersionStatusCheckedAt: '',
appVersionStatusSource: '',
- newProvider: { name: '', url: '', key: '', model: '', useTransform: false },
+ newProvider: { name: '', url: '', key: '', model: '', useTransform: false, openaiBridgeMaxRetries: 2 },
resetConfigLoading: false,
- editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false },
+ editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false, openaiBridgeMaxRetries: 2 },
newModelName: '',
currentClaudeConfig: '',
currentClaudeModel: '',
diff --git a/web-ui/modules/app.methods.agents.mjs b/web-ui/modules/app.methods.agents.mjs
index 2b423402..ca698995 100644
--- a/web-ui/modules/app.methods.agents.mjs
+++ b/web-ui/modules/app.methods.agents.mjs
@@ -718,6 +718,9 @@ export function createAgentsMethods(options = {}) {
return;
}
this.promptsSubTab = normalized;
+ this.$nextTick(() => {
+ document.querySelector('.main-panel')?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
+ });
},
async loadPromptsContent() {
diff --git a/web-ui/modules/app.methods.providers.mjs b/web-ui/modules/app.methods.providers.mjs
index f212855c..445d5a89 100644
--- a/web-ui/modules/app.methods.providers.mjs
+++ b/web-ui/modules/app.methods.providers.mjs
@@ -41,6 +41,13 @@ function findProviderByName(list, name) {
return (Array.isArray(list) ? list : []).find((item) => item && normalizeText(item.name) === target) || null;
}
+function normalizeBridgeMaxRetries(value, fallback = 2) {
+ const raw = Number(value);
+ const fallbackRaw = Number(fallback);
+ const base = Number.isFinite(raw) ? raw : (Number.isFinite(fallbackRaw) ? fallbackRaw : 2);
+ return Math.min(10, Math.max(2, Math.floor(base)));
+}
+
function normalizeProviderDraftState(target) {
if (!target || typeof target !== 'object') return;
if (typeof target.name === 'string') {
@@ -55,6 +62,9 @@ function normalizeProviderDraftState(target) {
if (typeof target.key === 'string') {
target.key = target.key.trim();
}
+ if (target.useTransform || target.openaiBridgeMaxRetries !== undefined) {
+ target.openaiBridgeMaxRetries = normalizeBridgeMaxRetries(target.openaiBridgeMaxRetries);
+ }
}
function maskKeyLocal(key) {
@@ -76,11 +86,14 @@ function getProviderValidationForContext(vm, mode = 'add') {
const url = normalizeProviderUrl(draft && draft.url);
const model = normalizeText(draft && draft.model);
const key = normalizeText(draft && draft.key);
+ const useTransform = !!(draft && draft.useTransform);
+ const openaiBridgeMaxRetries = normalizeBridgeMaxRetries(draft && draft.openaiBridgeMaxRetries);
const errors = {
name: '',
url: '',
key: '',
- model: ''
+ model: '',
+ openaiBridgeMaxRetries: ''
};
if (mode === 'add') {
@@ -111,14 +124,20 @@ function getProviderValidationForContext(vm, mode = 'add') {
errors.model = '模型名称必填';
}
+ if (useTransform && openaiBridgeMaxRetries < 2) {
+ errors.openaiBridgeMaxRetries = '重试次数最小为 2';
+ }
+
return {
mode,
name,
url,
key,
model,
+ useTransform,
+ openaiBridgeMaxRetries,
errors,
- ok: !errors.name && !errors.url && !errors.key && !errors.model
+ ok: !errors.name && !errors.url && !errors.key && !errors.model && !errors.openaiBridgeMaxRetries
};
}
@@ -172,7 +191,7 @@ export function createProvidersMethods(options = {}) {
normalizeProviderDraftState(this.newProvider);
const validation = getProviderValidationForContext(this, 'add');
if (!validation.ok) {
- return this.showMessage(validation.errors.name || validation.errors.url || validation.errors.key || validation.errors.model || this.t('toast.provider.fieldsRequired'), 'error');
+ return this.showMessage(validation.errors.name || validation.errors.url || validation.errors.key || validation.errors.model || validation.errors.openaiBridgeMaxRetries || this.t('toast.provider.fieldsRequired'), 'error');
}
try {
@@ -184,6 +203,7 @@ export function createProvidersMethods(options = {}) {
};
if (this.newProvider && this.newProvider.useTransform) {
payload.useTransform = true;
+ payload.openaiBridgeMaxRetries = validation.openaiBridgeMaxRetries;
}
const suggestedModel = validation.model;
const res = await api('add-provider', payload);
@@ -198,6 +218,7 @@ export function createProvidersMethods(options = {}) {
url: validation.url,
upstreamUrl: '',
codexmate_bridge: payload.useTransform ? 'openai' : '',
+ openaiBridgeMaxRetries: payload.useTransform ? validation.openaiBridgeMaxRetries : undefined,
key: maskKeyLocal(payload.key),
hasKey: !!payload.key,
models: suggestedModel ? [{ id: suggestedModel, name: suggestedModel, cost: null, contextWindow: undefined, maxTokens: undefined }] : [],
@@ -323,7 +344,8 @@ export function createProvidersMethods(options = {}) {
url: cloneUrl,
key: '',
model: '',
- useTransform: isTransform
+ useTransform: isTransform,
+ openaiBridgeMaxRetries: normalizeBridgeMaxRetries(provider.openaiBridgeMaxRetries)
};
this.showAddProviderKey = false;
this.showAddModal = true;
@@ -335,7 +357,8 @@ export function createProvidersMethods(options = {}) {
url: '',
key: '',
model: '',
- useTransform: false
+ useTransform: false,
+ openaiBridgeMaxRetries: 2
};
this.showAddProviderKey = false;
this.showAddModal = true;
@@ -363,7 +386,8 @@ export function createProvidersMethods(options = {}) {
nonEditable: typeof provider.nonEditable === 'boolean'
? provider.nonEditable
: this.isNonDeletableProvider(provider),
- useTransform: isTransformProvider
+ useTransform: isTransformProvider,
+ openaiBridgeMaxRetries: normalizeBridgeMaxRetries(provider.openaiBridgeMaxRetries)
};
this._editProviderOriginalKey = '';
this._editProviderRealKeyLoaded = false;
@@ -404,6 +428,9 @@ export function createProvidersMethods(options = {}) {
&& res.baseUrl.trim()
) {
this.editingProvider.url = normalizeProviderUrl(res.baseUrl);
+ if (res.maxRetries !== undefined) {
+ this.editingProvider.openaiBridgeMaxRetries = normalizeBridgeMaxRetries(res.maxRetries);
+ }
}
} catch (_) {
// ignore
@@ -420,12 +447,13 @@ export function createProvidersMethods(options = {}) {
normalizeProviderDraftState(this.editingProvider);
const validation = getProviderValidationForContext(this, 'edit');
if (!validation.ok) {
- return this.showMessage(validation.errors.name || validation.errors.url || this.t('toast.provider.urlRequired'), 'error');
+ return this.showMessage(validation.errors.name || validation.errors.url || validation.errors.openaiBridgeMaxRetries || this.t('toast.provider.urlRequired'), 'error');
}
const params = { name: validation.name, url: validation.url };
if (this.editingProvider && this.editingProvider.useTransform) {
params.useTransform = true;
+ params.openaiBridgeMaxRetries = validation.openaiBridgeMaxRetries;
}
if (this._editProviderRealKeyLoaded) {
const currentKey = typeof this.editingProvider.key === 'string' ? this.editingProvider.key : '';
@@ -449,7 +477,9 @@ export function createProvidersMethods(options = {}) {
...p,
url: validation.url,
key: keyUpdated ? maskKeyLocal(params.key) : p.key,
- hasKey: keyUpdated ? !!params.key : p.hasKey
+ hasKey: keyUpdated ? !!params.key : p.hasKey,
+ codexmate_bridge: params.useTransform ? 'openai' : p.codexmate_bridge,
+ openaiBridgeMaxRetries: params.useTransform ? validation.openaiBridgeMaxRetries : p.openaiBridgeMaxRetries
};
}
return p;
@@ -467,7 +497,7 @@ export function createProvidersMethods(options = {}) {
this.showEditProviderKey = false;
this._editProviderOriginalKey = '';
this._editProviderRealKeyLoaded = false;
- this.editingProvider = { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false };
+ this.editingProvider = { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false, openaiBridgeMaxRetries: 2 };
},
toggleEditProviderKey() {
@@ -550,7 +580,7 @@ export function createProvidersMethods(options = {}) {
closeAddModal() {
this.showAddModal = false;
this.showAddProviderKey = false;
- this.newProvider = { name: nextCodexProviderName(this.providersList), url: '', key: '', model: '', useTransform: false };
+ this.newProvider = { name: nextCodexProviderName(this.providersList), url: '', key: '', model: '', useTransform: false, openaiBridgeMaxRetries: 2 };
},
toggleAddProviderKey() {
diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs
index e41b924a..51dc1036 100644
--- a/web-ui/modules/i18n/locales/en.mjs
+++ b/web-ui/modules/i18n/locales/en.mjs
@@ -488,6 +488,8 @@ const en = Object.freeze({
'modal.claudeConfigEdit.title': 'Edit Claude Code config',
'field.useBuiltinTransform': 'Use built-in transform (OpenAI compatible)',
'hint.useBuiltinTransform': 'When enabled, base_url points to codexmate built-in transform service; Codex token is fixed to codexmate.',
+ 'field.transformMaxRetries': 'Built-in transform retries',
+ 'hint.transformMaxRetries': 'Default 2, minimum 2; number of retries after the first failed attempt.',
// Config template / agents modals
'modal.configTemplate.label': 'config.toml template',
diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs
index 2457aff6..b3624b30 100644
--- a/web-ui/modules/i18n/locales/ja.mjs
+++ b/web-ui/modules/i18n/locales/ja.mjs
@@ -490,6 +490,8 @@ const ja = Object.freeze({
'modal.claudeConfigEdit.title': 'Claude Code 設定編集',
'field.useBuiltinTransform': '内蔵変換を使用(OpenAI 形式互換)',
'hint.useBuiltinTransform': '有効時:書き込まれる base_url は codexmate 内蔵変換サービスを指します。Codex のトークンは codexmate に固定されます。',
+ 'field.transformMaxRetries': '内蔵変換の再試行回数',
+ 'hint.transformMaxRetries': '既定値は 2、最小値も 2。初回失敗後に再試行する最大回数です。',
// Config template / agents modals
'modal.configTemplate.label': 'config.toml テンプレート',
diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs
index 213490c1..9dcc1995 100644
--- a/web-ui/modules/i18n/locales/vi.mjs
+++ b/web-ui/modules/i18n/locales/vi.mjs
@@ -708,6 +708,8 @@ const vi = Object.freeze({
'modal.claudeConfigEdit.title': 'Chỉnh sửa cấu hình Claude Code',
'field.useBuiltinTransform': 'Dùng transform tích hợp (tương thích OpenAI)',
'hint.useBuiltinTransform': 'Khi bật, base_url trỏ đến dịch vụ transform tích hợp của codexmate.',
+ 'field.transformMaxRetries': 'Số lần thử lại transform tích hợp',
+ 'hint.transformMaxRetries': 'Mặc định 2, tối thiểu 2; số lần thử lại sau lần gọi đầu thất bại.',
'modal.configTemplate.title': 'Trình soạn thảo template cấu hình (xác nhận thủ công)',
'modal.configTemplate.label': 'Template config.toml',
'modal.configTemplate.placeholder': 'Chỉnh sửa template config.toml tại đây',
diff --git a/web-ui/modules/i18n/locales/zh-tw.mjs b/web-ui/modules/i18n/locales/zh-tw.mjs
index e05f2606..cb523ca2 100644
--- a/web-ui/modules/i18n/locales/zh-tw.mjs
+++ b/web-ui/modules/i18n/locales/zh-tw.mjs
@@ -488,6 +488,8 @@ const zhTw = Object.freeze({
'modal.claudeConfigEdit.title': '編輯 Claude Code 設定',
'field.useBuiltinTransform': '使用內建轉換(兼容 OpenAI 格式)',
'hint.useBuiltinTransform': '開啟後:寫入的 base_url 會指向 codexmate 內建轉換服務;Codex 使用的令牌固定為 codexmate。',
+ 'field.transformMaxRetries': '內建轉換重試次數',
+ 'hint.transformMaxRetries': '預設 2,最小 2;表示失敗後最多重試的次數。',
// Config template / agents modals
'modal.configTemplate.label': 'config.toml 模板',
diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs
index db332960..da0db376 100644
--- a/web-ui/modules/i18n/locales/zh.mjs
+++ b/web-ui/modules/i18n/locales/zh.mjs
@@ -488,6 +488,8 @@ const zh = Object.freeze({
'modal.claudeConfigEdit.title': '编辑 Claude Code 配置',
'field.useBuiltinTransform': '使用内建转换(兼容 OpenAI 格式)',
'hint.useBuiltinTransform': '开启后:写入的 base_url 会指向 codexmate 内建转换服务;Codex 使用的令牌固定为 codexmate。',
+ 'field.transformMaxRetries': '内建转换重试次数',
+ 'hint.transformMaxRetries': '默认 2,最小 2;表示失败后最多重试的次数。',
// Config template / agents modals
'modal.configTemplate.label': 'config.toml 模板',
diff --git a/web-ui/partials/index/modals-basic.html b/web-ui/partials/index/modals-basic.html
index d4e4fcdb..92c682f2 100644
--- a/web-ui/partials/index/modals-basic.html
+++ b/web-ui/partials/index/modals-basic.html
@@ -53,6 +53,19 @@
{{ t('field.useBuiltinTransform') }}
+
@@ -91,6 +104,19 @@
+
diff --git a/web-ui/partials/index/panel-prompts.html b/web-ui/partials/index/panel-prompts.html
index c3bd94b8..c41e92c1 100644
--- a/web-ui/partials/index/panel-prompts.html
+++ b/web-ui/partials/index/panel-prompts.html
@@ -5,9 +5,9 @@
id="panel-prompts"
role="tabpanel"
aria-labelledby="tab-prompts">
-
-
-
+
+
+
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js
index b34a1980..8716d5f0 100644
--- a/web-ui/res/web-ui-render.precompiled.js
+++ b/web-ui/res/web-ui-render.precompiled.js
@@ -6656,18 +6656,26 @@ return function render(_ctx, _cache) {
role: "tabpanel",
"aria-labelledby": "tab-prompts"
}, [
- _createElementVNode("div", { class: "segmented-control" }, [
+ _createElementVNode("div", {
+ class: "prompts-md-tabs",
+ role: "tablist",
+ "aria-label": _ctx.t('tab.prompts')
+ }, [
_createElementVNode("button", {
type: "button",
- class: _normalizeClass(['segment', { active: _ctx.promptsSubTab === 'codex' }]),
+ class: _normalizeClass(['prompts-md-tab', { active: _ctx.promptsSubTab === 'codex' }]),
+ role: "tab",
+ "aria-selected": _ctx.promptsSubTab === 'codex',
onClick: $event => (_ctx.switchPromptsSubTab('codex'))
- }, _toDisplayString(_ctx.t('prompts.subTab.codex')), 11 /* TEXT, CLASS, PROPS */, ["onClick"]),
+ }, _toDisplayString(_ctx.t('prompts.subTab.codex')), 11 /* TEXT, CLASS, PROPS */, ["aria-selected", "onClick"]),
_createElementVNode("button", {
type: "button",
- class: _normalizeClass(['segment', { active: _ctx.promptsSubTab === 'claude-project' }]),
+ class: _normalizeClass(['prompts-md-tab', { active: _ctx.promptsSubTab === 'claude-project' }]),
+ role: "tab",
+ "aria-selected": _ctx.promptsSubTab === 'claude-project',
onClick: $event => (_ctx.switchPromptsSubTab('claude-project'))
- }, _toDisplayString(_ctx.t('prompts.subTab.project')), 11 /* TEXT, CLASS, PROPS */, ["onClick"])
- ]),
+ }, _toDisplayString(_ctx.t('prompts.subTab.project')), 11 /* TEXT, CLASS, PROPS */, ["aria-selected", "onClick"])
+ ], 8 /* PROPS */, ["aria-label"]),
(_ctx.promptsSubTab === 'claude-project')
? (_openBlock(), _createElementBlock("div", {
key: 0,
@@ -7095,6 +7103,37 @@ return function render(_ctx, _cache) {
_createTextVNode(" " + _toDisplayString(_ctx.t('field.useBuiltinTransform')), 1 /* TEXT */)
])
]),
+ (_ctx.newProvider.useTransform)
+ ? (_openBlock(), _createElementBlock("div", {
+ key: 0,
+ class: "form-group"
+ }, [
+ _createElementVNode("label", { class: "form-label" }, _toDisplayString(_ctx.t('field.transformMaxRetries')), 1 /* TEXT */),
+ _withDirectives(_createElementVNode("input", {
+ "onUpdate:modelValue": $event => ((_ctx.newProvider.openaiBridgeMaxRetries) = $event),
+ type: "number",
+ min: "2",
+ max: "10",
+ step: "1",
+ class: _normalizeClass(['form-input', { invalid: !!_ctx.providerFieldError('add', 'openaiBridgeMaxRetries') }]),
+ onBlur: $event => (_ctx.normalizeProviderDraft('add'))
+ }, null, 42 /* CLASS, PROPS, NEED_HYDRATION */, ["onUpdate:modelValue", "onBlur"]), [
+ [
+ _vModelText,
+ _ctx.newProvider.openaiBridgeMaxRetries,
+ void 0,
+ { number: true }
+ ]
+ ]),
+ _createElementVNode("div", { class: "form-hint" }, _toDisplayString(_ctx.t('hint.transformMaxRetries')), 1 /* TEXT */),
+ (_ctx.providerFieldError('add', 'openaiBridgeMaxRetries'))
+ ? (_openBlock(), _createElementBlock("div", {
+ key: 0,
+ class: "form-hint form-error"
+ }, _toDisplayString(_ctx.providerFieldError('add', 'openaiBridgeMaxRetries')), 1 /* TEXT */))
+ : _createCommentVNode("v-if", true)
+ ]))
+ : _createCommentVNode("v-if", true),
_createElementVNode("div", { class: "btn-group" }, [
_createElementVNode("button", {
class: "btn btn-cancel",
@@ -7207,6 +7246,37 @@ return function render(_ctx, _cache) {
], 8 /* PROPS */, ["onClick", "title", "aria-label"])
])
]),
+ (_ctx.editingProvider.useTransform)
+ ? (_openBlock(), _createElementBlock("div", {
+ key: 0,
+ class: "form-group"
+ }, [
+ _createElementVNode("label", { class: "form-label" }, _toDisplayString(_ctx.t('field.transformMaxRetries')), 1 /* TEXT */),
+ _withDirectives(_createElementVNode("input", {
+ "onUpdate:modelValue": $event => ((_ctx.editingProvider.openaiBridgeMaxRetries) = $event),
+ type: "number",
+ min: "2",
+ max: "10",
+ step: "1",
+ class: _normalizeClass(['form-input', { invalid: !!_ctx.providerFieldError('edit', 'openaiBridgeMaxRetries') }]),
+ onBlur: $event => (_ctx.normalizeProviderDraft('edit'))
+ }, null, 42 /* CLASS, PROPS, NEED_HYDRATION */, ["onUpdate:modelValue", "onBlur"]), [
+ [
+ _vModelText,
+ _ctx.editingProvider.openaiBridgeMaxRetries,
+ void 0,
+ { number: true }
+ ]
+ ]),
+ _createElementVNode("div", { class: "form-hint" }, _toDisplayString(_ctx.t('hint.transformMaxRetries')), 1 /* TEXT */),
+ (_ctx.providerFieldError('edit', 'openaiBridgeMaxRetries'))
+ ? (_openBlock(), _createElementBlock("div", {
+ key: 0,
+ class: "form-hint form-error"
+ }, _toDisplayString(_ctx.providerFieldError('edit', 'openaiBridgeMaxRetries')), 1 /* TEXT */))
+ : _createCommentVNode("v-if", true)
+ ]))
+ : _createCommentVNode("v-if", true),
_createElementVNode("div", { class: "btn-group" }, [
_createElementVNode("button", {
class: "btn btn-cancel",
diff --git a/web-ui/styles/modals-core.css b/web-ui/styles/modals-core.css
index 35f45569..dcb14712 100644
--- a/web-ui/styles/modals-core.css
+++ b/web-ui/styles/modals-core.css
@@ -693,3 +693,35 @@
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
+
+.prompts-md-tabs {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ margin: 0 0 12px;
+ border-bottom: 1px solid var(--color-border-soft);
+}
+
+.prompts-md-tab {
+ padding: 7px 10px 8px;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ background: transparent;
+ box-shadow: none;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ font-size: var(--font-size-body);
+ font-weight: var(--font-weight-secondary);
+}
+
+.prompts-md-tab:hover {
+ color: var(--color-text-primary);
+ background: transparent;
+}
+
+.prompts-md-tab.active {
+ color: var(--color-brand-dark);
+ border-bottom-color: var(--color-brand);
+ background: transparent;
+ box-shadow: none;
+}