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') }} +
+ + +
{{ t('hint.transformMaxRetries') }}
+
{{ providerFieldError('add', 'openaiBridgeMaxRetries') }}
+
@@ -91,6 +104,19 @@
+
+ + +
{{ t('hint.transformMaxRetries') }}
+
{{ providerFieldError('edit', 'openaiBridgeMaxRetries') }}
+
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; +}