diff --git a/cli.js b/cli.js index 742b49aa..28bb6231 100644 --- a/cli.js +++ b/cli.js @@ -249,6 +249,8 @@ const PROVIDER_CACHE_CURRENT_MODEL_FILES = Object.freeze([ ]); const PROVIDER_CACHE_MAX_FILE_BYTES = 256 * 1024; const CODEXMATE_PREFERENCES_FILE = path.join(CODEXMATE_DIR, 'preferences.json'); +const CODEXMATE_TASK_OPENAI_CHAT_PROVIDER_FILE = path.join(CODEXMATE_DIR, 'task-openai-chat-provider.json'); +const CODEXMATE_LOCAL_AI_GATEWAY_FILE = path.join(CODEXMATE_DIR, 'local-ai-gateway.json'); const CODEXMATE_OPENCODE_DIR = path.join(CODEXMATE_DIR, 'opencode'); const CODEXMATE_OPENCODE_PROVIDER_STORE_FILE = path.join(CODEXMATE_OPENCODE_DIR, 'providers.json'); const CODEXMATE_SESSIONS_DIR = path.join(CODEXMATE_DIR, 'sessions'); @@ -385,16 +387,55 @@ const openaiBridgeHandler = createOpenaiBridgeHttpHandler({ }); const localBridgeHandler = createLocalBridgeHttpHandler({ - readConfigFn: readConfig, + readConfigFn: readLocalAiGatewayBridgeConfig, openaiBridgeFile: OPENAI_BRIDGE_SETTINGS_FILE, claudeProvidersFile: CLAUDE_LOCAL_PROVIDERS_FILE, localBridgeSettingsFile: LOCAL_BRIDGE_SETTINGS_FILE, expectedToken: typeof process.env.CODEXMATE_HTTP_TOKEN === 'string' ? process.env.CODEXMATE_HTTP_TOKEN.trim() : '', maxBodySize: MAX_API_BODY_SIZE, httpAgent: HTTP_KEEP_ALIVE_AGENT, - httpsAgent: HTTPS_KEEP_ALIVE_AGENT + httpsAgent: HTTPS_KEEP_ALIVE_AGENT, + onRequestLog: recordLocalAiRequestLog }); +let g_webRuntimeInfo = { + openOrigin: '', + openHost: '', + host: '', + port: 0 +}; + +const g_localAiRequestLog = []; + +function recordLocalAiRequestLog(item = {}) { + const safeItem = { + id: typeof item.id === 'string' ? item.id : `${Date.now()}`, + at: typeof item.at === 'string' ? item.at : new Date().toISOString(), + method: typeof item.method === 'string' ? item.method : '', + path: typeof item.path === 'string' ? item.path : '', + status: Number.isFinite(item.status) ? item.status : 0, + durationMs: Number.isFinite(item.durationMs) ? item.durationMs : 0, + provider: typeof item.upstreamProvider === 'string' && item.upstreamProvider ? item.upstreamProvider : (typeof item.provider === 'string' ? item.provider : ''), + attemptedProviders: typeof item.attemptedProviders === 'string' ? item.attemptedProviders : '', + model: typeof item.model === 'string' ? item.model : '', + summary: typeof item.summary === 'string' ? item.summary : '', + bodyPreview: typeof item.bodyPreview === 'string' ? item.bodyPreview.slice(0, 2000) : '', + upstreamCurlPreview: typeof item.upstreamCurlPreview === 'string' ? item.upstreamCurlPreview.slice(0, 4000) : '', + upstreamAuthSource: typeof item.upstreamAuthSource === 'string' ? item.upstreamAuthSource.slice(0, 500) : '', + responseContentType: typeof item.responseContentType === 'string' ? item.responseContentType.slice(0, 160) : '', + responsePreview: typeof item.responsePreview === 'string' ? item.responsePreview.slice(0, 4000) : '', + error: typeof item.error === 'string' ? item.error : '', + requestState: ['pending', 'done', 'failed'].includes(item.requestState) ? item.requestState : 'done' + }; + const existingIndex = g_localAiRequestLog.findIndex((entry) => entry && entry.id === safeItem.id); + if (existingIndex >= 0) { + g_localAiRequestLog.splice(existingIndex, 1, { ...g_localAiRequestLog[existingIndex], ...safeItem }); + } else { + g_localAiRequestLog.unshift(safeItem); + } + if (g_localAiRequestLog.length > 50) g_localAiRequestLog.length = 50; +} + function resolveWebPort() { const raw = process.env.CODEXMATE_PORT; if (!raw) return DEFAULT_WEB_PORT; @@ -980,7 +1021,7 @@ function normalizeSettingsTabPreference(value) { function normalizeMainTabPreference(value) { const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; - const allowed = new Set(['dashboard', 'config', 'sessions', 'usage', 'orchestration', 'market', 'plugins', 'docs', 'settings', 'trash', 'prompts']); + const allowed = new Set(['dashboard', 'config', 'sessions', 'usage', 'orchestration', 'local-ai', 'local-requests', 'market', 'plugins', 'docs', 'settings', 'trash', 'prompts']); return allowed.has(normalized) ? normalized : 'dashboard'; } @@ -1000,6 +1041,20 @@ function normalizePromptsSubTabPreference(value) { return normalized === 'claude-project' ? 'claude-project' : 'codex'; } +function normalizeLocalAiUiStatePreference(value = {}) { + const source = isPlainObject(value) ? value : {}; + const expanded = isPlainObject(source.localAiExpandedRequestIds) ? source.localAiExpandedRequestIds : {}; + return { + providerSetupOpen: source.providerSetupOpen === true, + localAiSelectedRequestId: typeof source.localAiSelectedRequestId === 'string' ? source.localAiSelectedRequestId.slice(0, 240) : '', + localAiExpandedRequestIds: Object.fromEntries( + Object.entries(expanded) + .filter(([key, enabled]) => key && enabled === true) + .slice(0, 20) + ) + }; +} + function normalizeWebUiPreferences(value = {}) { const source = isPlainObject(value) ? value : {}; const navigation = isPlainObject(source.navigation) ? source.navigation : {}; @@ -1012,6 +1067,7 @@ function normalizeWebUiPreferences(value = {}) { sessionsUsageTimeRange: normalizeUsageTimeRangePreference(source.sessionsUsageTimeRange), promptsSubTab: normalizePromptsSubTabPreference(source.promptsSubTab), projectClaudeMdPath: typeof source.projectClaudeMdPath === 'string' ? source.projectClaudeMdPath : '', + localAiUiState: normalizeLocalAiUiStatePreference(source.localAiUiState), navigation: { mainTab: normalizeMainTabPreference(navigation.mainTab), configMode: normalizeConfigModePreference(navigation.configMode), @@ -1037,6 +1093,10 @@ function setWebUiPreferences(params = {}) { navigation: { ...(isPlainObject(current.navigation) ? current.navigation : {}), ...(isPlainObject(incoming.navigation) ? incoming.navigation : {}) + }, + localAiUiState: { + ...(isPlainObject(current.localAiUiState) ? current.localAiUiState : {}), + ...(isPlainObject(incoming.localAiUiState) ? incoming.localAiUiState : {}) } }); preferences.webUi = next; @@ -12521,6 +12581,27 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'providers-health': result = await buildAllProvidersHealthReport(params || {}); break; + case 'save-task-openai-chat-provider': + result = saveTaskOpenAiChatProvider(params || {}); + break; + case 'local-ai-service-overview': + result = buildLocalAiServiceStatus(); + break; + case 'get-local-ai-provider': + result = getLocalAiProvider(params || {}); + break; + case 'save-local-ai-provider': + result = saveLocalAiProvider(params || {}); + break; + case 'select-local-ai-provider': + result = selectLocalAiProvider(params || {}); + break; + case 'select-task-openai-chat-provider': + result = selectTaskOpenAiChatProvider(params || {}); + break; + case 'save-task-webview-bridge-config': + result = saveTaskWebviewBridgeConfig(params || {}); + break; case 'doctor': { const doctorParams = isPlainObject(params) ? params : {}; @@ -13294,6 +13375,12 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser ? '::1' : (host === '0.0.0.0' ? DEFAULT_WEB_OPEN_HOST : host); const openUrl = `http://${formatHostForUrl(openHost)}:${port}`; + g_webRuntimeInfo = { + openOrigin: openUrl, + openHost, + host, + port + }; server.listen(port, host, () => { console.log('\n✓ Web UI 已启动'); const willOpenBrowser = !!openBrowser && !process.env.CODEXMATE_NO_BROWSER; @@ -16074,6 +16161,8 @@ function buildTaskOverviewPayload(options = {}) { return { workflows: workflowCatalog.workflows, openAiChatStatus: buildTaskOpenAiChatStatus(), + localAiServiceStatus: buildLocalAiServiceStatus(), + webviewBridgeConfig: readTaskOpenAiChatProviderStore().webviewBridge, warnings, queue, runs, @@ -16145,6 +16234,36 @@ function buildOpenAiChatEndpointUrl(baseUrl) { return joinApiUrl(trimmed, 'chat/completions'); } + +function isLoopbackHostname(hostname) { + const value = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, ''); + return value === 'localhost' || value === '127.0.0.1' || value === '::1'; +} + +function isLoopbackHttpsDefaultPortUrl(rawUrl) { + const raw = String(rawUrl || '').trim(); + if (!raw) return false; + try { + const parsed = new URL(raw); + if (parsed.protocol !== 'https:' || !isLoopbackHostname(parsed.hostname)) return false; + return !parsed.port || parsed.port === '443'; + } catch (_) { + return false; + } +} + +function describeLoopbackHttpsDefaultPortUrl(name, rawUrl) { + if (!isLoopbackHttpsDefaultPortUrl(rawUrl)) return ''; + let endpoint = String(rawUrl || '').trim(); + try { + const parsed = new URL(endpoint); + parsed.username = ''; + parsed.password = ''; + endpoint = parsed.toString(); + } catch (_) {} + return `Provider ${name} points at loopback HTTPS default port (${endpoint}); use the real upstream URL, or http://127.0.0.1:/v1 for a local HTTP upstream.`; +} + function redactTaskEndpointUrl(endpointUrl = '') { const raw = String(endpointUrl || '').trim(); if (!raw) return ''; @@ -16168,6 +16287,9 @@ function redactTaskEndpointUrl(endpointUrl = '') { function pickTaskProviderModel(providerName, provider, config) { const currentModels = readCurrentModels(); + const selectedModel = typeof provider?.selected_model === 'string' + ? provider.selected_model.trim() + : (typeof provider?.selectedModel === 'string' ? provider.selectedModel.trim() : ''); const savedModel = currentModels && typeof currentModels[providerName] === 'string' ? currentModels[providerName].trim() : ''; @@ -16181,7 +16303,7 @@ function pickTaskProviderModel(providerName, provider, config) { return ''; }) .find(Boolean) || ''; - return savedModel || (activeProvider === providerName ? activeModel : '') || firstProviderModel; + return selectedModel || savedModel || (activeProvider === providerName ? activeModel : '') || firstProviderModel; } function pickTaskProviderTemperature(provider) { @@ -16198,27 +16320,457 @@ function pickTaskProviderTemperature(provider) { return value; } -function pickTaskOpenAiChatProviderName(config) { +function listTaskProviderModels(provider) { + const providerModels = Array.isArray(provider && provider.models) ? provider.models : []; + return providerModels + .map((item) => { + if (typeof item === 'string') return item.trim(); + if (item && typeof item === 'object') return String(item.id || item.name || item.model || '').trim(); + return ''; + }) + .filter((item, index, list) => item && list.indexOf(item) === index); +} + +function normalizeTaskOpenAiChatProviderStore(raw) { + const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; + const providersSource = source.providers && typeof source.providers === 'object' && !Array.isArray(source.providers) + ? source.providers + : {}; + const providers = {}; + Object.keys(providersSource).forEach((name) => { + const key = typeof name === 'string' ? name.trim() : ''; + const provider = providersSource[name]; + if (!key || !provider || typeof provider !== 'object' || Array.isArray(provider)) return; + const models = listTaskProviderModels(provider); + const selectedModel = typeof provider.selected_model === 'string' + ? provider.selected_model.trim() + : (typeof provider.selectedModel === 'string' ? provider.selectedModel.trim() : ''); + if (selectedModel && !models.includes(selectedModel)) { + models.push(selectedModel); + } + const type = typeof provider.type === 'string' && ['openai', 'claude'].includes(provider.type.trim().toLowerCase()) + ? provider.type.trim().toLowerCase() + : 'openai'; + const baseUrl = typeof provider.base_url === 'string' + ? provider.base_url.trim() + : (typeof provider.baseUrl === 'string' ? provider.baseUrl.trim() : (typeof provider.endpoint === 'string' ? provider.endpoint.trim() : '')); + const authSource = provider.auth && typeof provider.auth === 'object' && !Array.isArray(provider.auth) ? provider.auth : {}; + const apiKey = typeof provider.preferred_auth_method === 'string' + ? provider.preferred_auth_method.trim() + : (typeof authSource.apiKey === 'string' ? authSource.apiKey.trim() : (typeof provider.apiKey === 'string' ? provider.apiKey.trim() : '')); + providers[key] = { + name: typeof provider.name === 'string' && provider.name.trim() ? provider.name.trim() : key, + type, + base_url: baseUrl, + endpoint: baseUrl, + wire_api: type === 'claude' ? 'messages' : 'chat_completions', + preferred_auth_method: apiKey, + models, + enabled: provider.enabled !== false, + temperature: Number.isFinite(Number(provider.temperature)) ? Number(provider.temperature) : undefined, + ignore_upstream_errors: provider.ignore_upstream_errors === true || provider.ignoreUpstreamErrors === true + }; + if (selectedModel) providers[key].selected_model = selectedModel; + if (providers[key].temperature === undefined) delete providers[key].temperature; + }); + const selectedModelsSource = source.selectedModels && typeof source.selectedModels === 'object' && !Array.isArray(source.selectedModels) + ? source.selectedModels + : {}; + const selectedModels = {}; + Object.keys(providers).forEach((name) => { + const rawSelected = typeof selectedModelsSource[name] === 'string' + ? selectedModelsSource[name].trim() + : (typeof providers[name].selected_model === 'string' ? providers[name].selected_model.trim() : ''); + const model = rawSelected || providers[name].models[0] || ''; + if (model) { + if (!providers[name].models.includes(model)) providers[name].models.push(model); + providers[name].selected_model = model; + selectedModels[name] = model; + } + }); + const selectedProvider = typeof source.selectedProvider === 'string' ? source.selectedProvider.trim() : ''; + const bridgeSource = source.webviewBridge && typeof source.webviewBridge === 'object' && !Array.isArray(source.webviewBridge) + ? source.webviewBridge + : {}; + const webviewBridge = { + name: typeof bridgeSource.name === 'string' && bridgeSource.name.trim() ? bridgeSource.name.trim() : 'codexmate-kilo-shim', + transport: typeof bridgeSource.transport === 'string' && bridgeSource.transport.trim() ? bridgeSource.transport.trim() : 'in-page-postMessage-adapter', + allowedMessageTypes: Array.isArray(bridgeSource.allowedMessageTypes) ? bridgeSource.allowedMessageTypes.map((item) => String(item || '').trim()).filter(Boolean) : [], + blockedMessageTypes: Array.isArray(bridgeSource.blockedMessageTypes) ? bridgeSource.blockedMessageTypes.map((item) => String(item || '').trim()).filter(Boolean) : [] + }; + return { + selectedProvider: selectedProvider && providers[selectedProvider] ? selectedProvider : (Object.keys(providers)[0] || ''), + selectedModels, + providers, + webviewBridge + }; +} + +function readTaskOpenAiChatProviderStore() { + const parsed = readJsonObjectFromFile(CODEXMATE_TASK_OPENAI_CHAT_PROVIDER_FILE, {}); + return normalizeTaskOpenAiChatProviderStore(parsed && parsed.ok && parsed.data ? parsed.data : {}); +} + +function writeTaskOpenAiChatProviderStore(store) { + ensureDir(path.dirname(CODEXMATE_TASK_OPENAI_CHAT_PROVIDER_FILE)); + writeJsonAtomic(CODEXMATE_TASK_OPENAI_CHAT_PROVIDER_FILE, normalizeTaskOpenAiChatProviderStore(store)); +} + +function saveTaskOpenAiChatProvider(params = {}) { + const name = typeof params.name === 'string' ? params.name.trim() : ''; + const baseUrl = normalizeBaseUrl(params.url || params.baseUrl || ''); + const apiKey = typeof params.key === 'string' ? params.key.trim() : ''; + const model = typeof params.model === 'string' ? params.model.trim() : ''; + const type = typeof params.type === 'string' && ['openai', 'claude'].includes(params.type.trim().toLowerCase()) + ? params.type.trim().toLowerCase() + : 'openai'; + if (!name || !baseUrl || !apiKey || !model) { + return { error: 'Provider name, URL, API key and model are required' }; + } + if (!isValidProviderName(name)) { + return { error: 'Provider name only supports letters, numbers, dot, underscore and dash' }; + } + if (!isValidHttpUrl(baseUrl)) { + return { error: 'Provider URL only supports http/https' }; + } + const store = readTaskOpenAiChatProviderStore(); + const previous = store.providers[name] && typeof store.providers[name] === 'object' ? store.providers[name] : {}; + const models = listTaskProviderModels(previous); + if (model && !models.includes(model)) models.unshift(model); + store.providers[name] = { + name, + type, + base_url: baseUrl, + endpoint: baseUrl, + wire_api: type === 'claude' ? 'messages' : 'chat_completions', + preferred_auth_method: apiKey, + models, + enabled: params.enabled !== false, + selected_model: model + }; + store.selectedProvider = name; + store.selectedModels = { ...(store.selectedModels || {}), [name]: model }; + writeTaskOpenAiChatProviderStore(store); + return { + success: true, + providerName: name, + type, + model, + storePath: '~/.codexmate/task-openai-chat-provider.json' + }; +} + +function readLocalAiGatewayStore() { + const parsed = readJsonObjectFromFile(CODEXMATE_LOCAL_AI_GATEWAY_FILE, {}); + return normalizeTaskOpenAiChatProviderStore(parsed && parsed.ok && parsed.data ? parsed.data : {}); +} + +function writeLocalAiGatewayStore(store) { + ensureDir(path.dirname(CODEXMATE_LOCAL_AI_GATEWAY_FILE)); + writeJsonAtomic(CODEXMATE_LOCAL_AI_GATEWAY_FILE, normalizeTaskOpenAiChatProviderStore(store)); +} + +function readLocalAiGatewayBridgeConfig() { + const store = readLocalAiGatewayStore(); + const providers = store.providers && typeof store.providers === 'object' ? store.providers : {}; + const modelProviders = {}; + Object.keys(providers).forEach((key) => { + const provider = providers[key]; + if (!provider || typeof provider !== 'object') return; + const type = provider.type === 'claude' ? 'claude' : 'openai'; + if (type !== 'openai') return; + if (provider.enabled === false) return; + const baseUrl = typeof provider.base_url === 'string' && provider.base_url.trim() + ? provider.base_url.trim() + : (typeof provider.endpoint === 'string' ? provider.endpoint.trim() : ''); + if (!baseUrl) return; + if (describeLoopbackHttpsDefaultPortUrl(key, baseUrl)) return; + const models = listTaskProviderModels(provider); + modelProviders[key] = { + name: provider.name || key, + base_url: baseUrl, + endpoint: baseUrl, + wire_api: 'chat_completions', + preferred_auth_method: typeof provider.preferred_auth_method === 'string' ? provider.preferred_auth_method : '', + models, + selected_model: provider.selected_model || store.selectedModels?.[key] || '', + enabled: provider.enabled !== false, + temperature: Number.isFinite(Number(provider.temperature)) ? Number(provider.temperature) : undefined, + ignore_upstream_errors: provider.ignore_upstream_errors === true || provider.ignoreUpstreamErrors === true + }; + if (modelProviders[key].temperature === undefined) delete modelProviders[key].temperature; + }); + const selectedProvider = typeof store.selectedProvider === 'string' && modelProviders[store.selectedProvider] + ? store.selectedProvider + : (Object.keys(modelProviders)[0] || ''); + return { model_provider: selectedProvider, selectedProvider, model_providers: modelProviders }; +} + +function buildLocalAiProviderOption(key, provider, store) { + const models = listTaskProviderModels(provider); + const type = provider.type === 'claude' ? 'claude' : 'openai'; + const endpoint = provider.endpoint || provider.base_url || ''; + const endpointIssue = describeLoopbackHttpsDefaultPortUrl(key, endpoint); + const hasApiKey = typeof provider.preferred_auth_method === 'string' && provider.preferred_auth_method.trim().length > 0; + const selectedModel = provider.selected_model || store.selectedModels?.[key] || ''; + return { + key, + name: provider.name || key, + type, + endpoint: redactTaskEndpointUrl(endpoint), + baseUrl: redactTaskEndpointUrl(provider.base_url || endpoint), + wireApi: type === 'claude' ? 'messages' : 'chat_completions', + models, + selectedModel, + enabled: provider.enabled !== false, + ready: provider.enabled !== false && !!endpoint && !endpointIssue, + configError: endpointIssue, + hasApiKey, + hasExtraHeaders: false, + ignoreUpstreamErrors: provider.ignore_upstream_errors === true || provider.ignoreUpstreamErrors === true, + routeRole: type === 'openai' ? 'gateway-upstream' : 'managed-provider' + }; +} + +function getLocalAiProvider(params = {}) { + const requestedName = typeof params.name === 'string' ? params.name.trim() : ''; + if (!requestedName) return { error: 'Provider name is required' }; + const store = readLocalAiGatewayStore(); + const providers = store.providers && typeof store.providers === 'object' ? store.providers : {}; + const key = providers[requestedName] + ? requestedName + : Object.keys(providers).find((item) => providers[item] && providers[item].name === requestedName); + if (!key || !providers[key]) return { error: 'Provider not found' }; + const provider = providers[key] || {}; + const option = buildLocalAiProviderOption(key, provider, store); + const endpoint = provider.endpoint || provider.base_url || ''; + return { + ...option, + endpoint, + baseUrl: provider.base_url || endpoint, + apiKey: typeof provider.preferred_auth_method === 'string' ? provider.preferred_auth_method : '' + }; +} + +function buildLocalAiServiceStatus() { + const store = readLocalAiGatewayStore(); + const providers = store.providers && typeof store.providers === 'object' ? store.providers : {}; + const providerOptions = Object.keys(providers).map((key) => buildLocalAiProviderOption(key, providers[key] || {}, store)); + const routeProviderOptions = providerOptions.filter((provider) => provider.type === 'openai'); + const selectedProvider = store.selectedProvider && providers[store.selectedProvider] + ? store.selectedProvider + : (providerOptions[0]?.key || ''); + const selectedRouteProvider = routeProviderOptions.find((item) => item.key === selectedProvider) || null; + const activeRouteProvider = selectedRouteProvider || routeProviderOptions.find((item) => item.ready) || routeProviderOptions[0] || null; + const gatewayReady = routeProviderOptions.some((item) => item.ready); + const gatewayBasePath = '/bridge/local/v1'; + const gatewayOrigin = typeof g_webRuntimeInfo.openOrigin === 'string' ? g_webRuntimeInfo.openOrigin.replace(/\/+$/, '') : ''; + const gatewayLocalBaseUrl = gatewayOrigin ? `${gatewayOrigin}${gatewayBasePath}` : ''; + return { + ok: true, + service: 'local-ai-service', + adapter: 'local-openai-compatible-gateway', + storePath: '~/.codexmate/local-ai-gateway.json', + selectedProvider, + providerOptions, + routeProviderOptions, + activeRouteProvider: activeRouteProvider ? activeRouteProvider.key : '', + ready: gatewayReady, + requestLog: g_localAiRequestLog.slice(0, 20), + configError: '', + isolation: { + independentConfig: true, + codexConfigCoupled: false, + taskConfigCoupled: false, + source: 'local-ai-gateway' + }, + gateway: { + basePath: gatewayBasePath, + localBaseUrl: gatewayLocalBaseUrl, + chatCompletionsPath: '/bridge/local/v1/chat/completions', + modelsPath: '/bridge/local/v1/models', + healthPath: '/bridge/local/v1', + compatibility: 'OpenAI-compatible Chat Completions', + authMode: 'loopback-open; remote requires CODEXMATE_HTTP_TOKEN', + manualSwitching: false, + routingPolicy: { + mode: 'automatic', + retry: true, + failover: true, + freezeGuard: true, + circuitBreakerThreshold: 3, + cooldownMs: 5 * 60 * 1000 + } + } + }; +} + +function saveLocalAiProvider(params = {}) { + const name = typeof params.name === 'string' ? params.name.trim() : ''; + const originalName = typeof params.originalName === 'string' ? params.originalName.trim() : ''; + const baseUrl = normalizeBaseUrl(params.url || params.baseUrl || ''); + const apiKey = typeof params.key === 'string' ? params.key.trim() : ''; + const model = typeof params.model === 'string' ? params.model.trim() : ''; + const type = typeof params.type === 'string' && ['openai', 'claude'].includes(params.type.trim().toLowerCase()) + ? params.type.trim().toLowerCase() + : 'openai'; + const store = readLocalAiGatewayStore(); + const previousName = originalName && store.providers && store.providers[originalName] + ? originalName + : name; + const previous = store.providers[previousName] && typeof store.providers[previousName] === 'object' ? store.providers[previousName] : {}; + const savedApiKey = typeof previous.preferred_auth_method === 'string' ? previous.preferred_auth_method.trim() : ''; + const nextApiKey = apiKey || savedApiKey; + if (!name || !baseUrl) { + return { error: 'Provider name and URL are required' }; + } + if (!isValidProviderName(name)) { + return { error: 'Provider name only supports letters, numbers, dot, underscore and dash' }; + } + if (!isValidHttpUrl(baseUrl)) { + return { error: 'Provider URL only supports http/https' }; + } + const endpointIssue = describeLoopbackHttpsDefaultPortUrl(name, baseUrl); + if (endpointIssue) { + return { error: endpointIssue }; + } + const models = model ? listTaskProviderModels(previous) : []; + if (model && !models.includes(model)) models.unshift(model); + const temperature = Number.isFinite(Number(params.temperature)) ? Number(params.temperature) : undefined; + const ignoreUpstreamErrors = params.ignoreUpstreamErrors === true || params.ignore_upstream_errors === true; + store.providers[name] = { + name, + type, + base_url: baseUrl, + endpoint: baseUrl, + wire_api: type === 'claude' ? 'messages' : 'chat_completions', + preferred_auth_method: nextApiKey, + models, + enabled: params.enabled !== false, + temperature, + ignore_upstream_errors: ignoreUpstreamErrors + }; + if (model) store.providers[name].selected_model = model; + if (previousName !== name && store.providers[previousName]) { + delete store.providers[previousName]; + if (store.selectedModels && typeof store.selectedModels === 'object') { + delete store.selectedModels[previousName]; + } + } + if (store.providers[name].temperature === undefined) delete store.providers[name].temperature; + store.selectedProvider = name; + store.selectedModels = { ...(store.selectedModels || {}) }; + if (model) store.selectedModels[name] = model; + else delete store.selectedModels[name]; + writeLocalAiGatewayStore(store); + return { + success: true, + providerName: name, + type, + model, + storePath: '~/.codexmate/local-ai-gateway.json' + }; +} + +function selectLocalAiProvider(params = {}) { + const name = typeof params.name === 'string' ? params.name.trim() : String(params.providerName || '').trim(); + const model = typeof params.model === 'string' ? params.model.trim() : ''; + if (!name) return { error: 'Provider name is required' }; + const store = readLocalAiGatewayStore(); + const provider = store.providers && store.providers[name] && typeof store.providers[name] === 'object' + ? store.providers[name] + : null; + if (!provider) return { error: `Local AI provider does not exist: ${name}` }; + const models = listTaskProviderModels(provider); + const selectedModel = model || provider.selected_model || models[0] || ''; + if (!selectedModel) return { error: `Local AI provider ${name} has no model` }; + if (!models.includes(selectedModel)) return { error: `Local AI provider ${name} does not include model: ${selectedModel}` }; + provider.selected_model = selectedModel; + store.selectedProvider = name; + store.selectedModels = { ...(store.selectedModels || {}), [name]: selectedModel }; + writeLocalAiGatewayStore(store); + return { + success: true, + providerName: name, + model: selectedModel, + storePath: '~/.codexmate/local-ai-gateway.json' + }; +} + +function selectTaskOpenAiChatProvider(params = {}) { + const name = typeof params.name === 'string' ? params.name.trim() : String(params.providerName || '').trim(); + const model = typeof params.model === 'string' ? params.model.trim() : ''; + if (!name) return { error: 'Provider name is required' }; + const store = readTaskOpenAiChatProviderStore(); + const provider = store.providers && store.providers[name] && typeof store.providers[name] === 'object' + ? store.providers[name] + : null; + if (!provider) return { error: `Task provider does not exist: ${name}` }; + const models = listTaskProviderModels(provider); + const selectedModel = model || provider.selected_model || models[0] || ''; + if (!selectedModel) return { error: `Task provider ${name} has no model` }; + if (!models.includes(selectedModel)) return { error: `Task provider ${name} does not include model: ${selectedModel}` }; + provider.selected_model = selectedModel; + store.selectedProvider = name; + store.selectedModels = { ...(store.selectedModels || {}), [name]: selectedModel }; + writeTaskOpenAiChatProviderStore(store); + return { + success: true, + providerName: name, + model: selectedModel, + storePath: '~/.codexmate/task-openai-chat-provider.json' + }; +} + +function saveTaskWebviewBridgeConfig(params = {}) { + const store = readTaskOpenAiChatProviderStore(); + const name = typeof params.name === 'string' && params.name.trim() ? params.name.trim() : 'codexmate-kilo-shim'; + const transport = typeof params.transport === 'string' && params.transport.trim() ? params.transport.trim() : 'in-page-postMessage-adapter'; + const allowedMessageTypes = Array.isArray(params.allowedMessageTypes) + ? params.allowedMessageTypes.map((item) => String(item || '').trim()).filter(Boolean) + : String(params.allowedMessageTypes || '').split(/[\n,]+/g).map((item) => item.trim()).filter(Boolean); + const blockedMessageTypes = Array.isArray(params.blockedMessageTypes) + ? params.blockedMessageTypes.map((item) => String(item || '').trim()).filter(Boolean) + : String(params.blockedMessageTypes || '').split(/[\n,]+/g).map((item) => item.trim()).filter(Boolean); + store.webviewBridge = { name, transport, allowedMessageTypes, blockedMessageTypes }; + writeTaskOpenAiChatProviderStore(store); + return { success: true, webviewBridge: store.webviewBridge, storePath: '~/.codexmate/task-openai-chat-provider.json' }; +} + +function pickTaskOpenAiChatProviderName(config, taskProviderStore = null) { + const store = taskProviderStore && typeof taskProviderStore === 'object' + ? taskProviderStore + : readTaskOpenAiChatProviderStore(); + const providers = store && store.providers && typeof store.providers === 'object' ? store.providers : {}; + if (store && typeof store.selectedProvider === 'string' && store.selectedProvider.trim()) { + const selectedProvider = store.selectedProvider.trim(); + if (providers[selectedProvider] && providers[selectedProvider].type !== 'claude') return selectedProvider; + } const taskProvider = typeof config.task_openai_chat_provider === 'string' ? config.task_openai_chat_provider.trim() : ''; - if (taskProvider) { + if (taskProvider && providers[taskProvider] && providers[taskProvider].type !== 'claude') { return taskProvider; } - return typeof config.model_provider === 'string' ? config.model_provider.trim() : ''; + return Object.keys(providers).find((name) => providers[name] && providers[name].type !== 'claude') || ''; } -function resolveTaskOpenAiChatConfig() { - const configResult = readConfigOrVirtualDefault(); - const config = configResult && configResult.config && typeof configResult.config === 'object' ? configResult.config : {}; - const providerName = pickTaskOpenAiChatProviderName(config); - if (!providerName) { - return { error: '未设置当前 OpenAI Chat 提供商' }; +function taskOpenAiChatProviderHasAuth(config) { + const hasApiKey = typeof config.apiKey === 'string' && config.apiKey.trim(); + const hasExtraHeaders = config.extraHeaders + && typeof config.extraHeaders === 'object' + && !Array.isArray(config.extraHeaders) + && Object.keys(config.extraHeaders).length > 0; + return !!(hasApiKey || hasExtraHeaders); +} + +function resolveTaskOpenAiChatProviderConfig(providerName, provider, config) { + const name = typeof providerName === 'string' ? providerName.trim() : ''; + if (!name) { + return { error: 'OpenAI Chat 提供商名称为空' }; } - const providers = config.model_providers && typeof config.model_providers === 'object' ? config.model_providers : {}; - const provider = providers[providerName]; if (!provider || typeof provider !== 'object') { - return { error: `OpenAI Chat 提供商不存在: ${providerName}` }; + return { error: `OpenAI Chat 提供商不存在: ${name}` }; } const bridgeType = typeof provider.codexmate_bridge === 'string' ? provider.codexmate_bridge.trim() : ''; @@ -16228,7 +16780,7 @@ function resolveTaskOpenAiChatConfig() { let apiKey = typeof provider.preferred_auth_method === 'string' ? provider.preferred_auth_method.trim() : ''; let extraHeaders = {}; if (isOpenaiBridgeProvider) { - const upstream = resolveOpenaiBridgeUpstream(OPENAI_BRIDGE_SETTINGS_FILE, providerName); + const upstream = resolveOpenaiBridgeUpstream(OPENAI_BRIDGE_SETTINGS_FILE, name); if (upstream && !upstream.error) { baseUrl = upstream.baseUrl || baseUrl; apiKey = upstream.apiKey || apiKey; @@ -16238,31 +16790,109 @@ function resolveTaskOpenAiChatConfig() { } } - const model = pickTaskProviderModel(providerName, provider, config); + const model = pickTaskProviderModel(name, provider, config); if (!baseUrl) { - return { error: `OpenAI Chat 提供商 ${providerName} 缺少 base_url` }; + return { error: `OpenAI Chat 提供商 ${name} 缺少 base_url` }; } if (!isValidHttpUrl(baseUrl)) { - return { error: `OpenAI Chat 提供商 ${providerName} 的 base_url 无效` }; + return { error: `OpenAI Chat 提供商 ${name} 的 base_url 无效` }; } if (!model) { - return { error: `OpenAI Chat 提供商 ${providerName} 未设置模型` }; + return { error: `OpenAI Chat 提供商 ${name} 未设置模型` }; } return { - providerName, + providerName: name, baseUrl, endpointUrl: buildOpenAiChatEndpointUrl(baseUrl), apiKey, extraHeaders, model, + models: typeof listTaskProviderModels === 'function' + ? listTaskProviderModels(provider) + : (Array.isArray(provider && provider.models) ? provider.models.map((item) => String(item || '').trim()).filter(Boolean) : []), temperature: pickTaskProviderTemperature(provider) }; } +function resolveTaskOpenAiChatConfig() { + const configResult = readConfigOrVirtualDefault(); + const config = configResult && configResult.config && typeof configResult.config === 'object' ? configResult.config : {}; + const taskProviderStore = readTaskOpenAiChatProviderStore(); + const providerName = pickTaskOpenAiChatProviderName(config, taskProviderStore); + if (!providerName) { + return { error: '未配置 task-local OpenAI Chat 提供商' }; + } + const taskProviders = taskProviderStore.providers && typeof taskProviderStore.providers === 'object' ? taskProviderStore.providers : {}; + const provider = taskProviders[providerName]; + return resolveTaskOpenAiChatProviderConfig(providerName, provider, config); +} + +function listTaskOpenAiChatConfigs() { + const configResult = readConfigOrVirtualDefault(); + const config = configResult && configResult.config && typeof configResult.config === 'object' ? configResult.config : {}; + const taskProviderStore = readTaskOpenAiChatProviderStore(); + const taskProviders = taskProviderStore.providers && typeof taskProviderStore.providers === 'object' ? taskProviderStore.providers : {}; + const providers = taskProviders; + const primaryName = pickTaskOpenAiChatProviderName(config, taskProviderStore); + const orderedNames = []; + if (primaryName) orderedNames.push(primaryName); + Object.keys(providers).forEach((name) => { + if (!orderedNames.includes(name)) orderedNames.push(name); + }); + const configs = []; + const errors = []; + for (const name of orderedNames) { + const provider = providers[name]; + if (provider && provider.enabled === false) { + continue; + } + if (provider && provider.type === 'claude') { + continue; + } + const isPrimary = name === primaryName; + const wireApi = typeof provider?.wire_api === 'string' ? provider.wire_api.trim() : ''; + const bridgeType = typeof provider?.codexmate_bridge === 'string' ? provider.codexmate_bridge.trim() : ''; + const baseUrl = typeof provider?.base_url === 'string' ? provider.base_url.trim() : ''; + const looksChatCompatible = isPrimary + || wireApi === 'chat_completions' + || bridgeType === 'openai' + || baseUrl.includes('/bridge/openai/'); + if (!looksChatCompatible) { + continue; + } + const resolved = resolveTaskOpenAiChatProviderConfig(name, provider, config); + if (resolved && resolved.error) { + if (isPrimary) errors.push(resolved.error); + continue; + } + resolved.primary = isPrimary; + configs.push(resolved); + } + return { configs, errors, primaryName }; +} function buildTaskOpenAiChatStatus() { const requestConfig = resolveTaskOpenAiChatConfig(); - if (requestConfig && requestConfig.error) { + const candidatesResult = typeof listTaskOpenAiChatConfigs === 'function' ? listTaskOpenAiChatConfigs() : { configs: [] }; + const providerOptions = (Array.isArray(candidatesResult.configs) ? candidatesResult.configs : []).map((item) => { + const hasApiKey = !!(item && typeof item.apiKey === 'string' && item.apiKey.trim()); + const hasExtraHeaders = !!(item + && item.extraHeaders + && typeof item.extraHeaders === 'object' + && !Array.isArray(item.extraHeaders) + && Object.keys(item.extraHeaders).length > 0); return { + name: item.providerName || '', + model: item.model || '', + models: Array.isArray(item.models) ? item.models : [], + endpoint: redactTaskEndpointUrl(item.endpointUrl || ''), + primary: !!item.primary, + ready: hasApiKey || hasExtraHeaders, + hasApiKey, + hasExtraHeaders + }; + }).filter((item) => item.name); + if (requestConfig && requestConfig.error) { + const status = { ok: false, ready: false, error: requestConfig.error, @@ -16272,6 +16902,8 @@ function buildTaskOpenAiChatStatus() { hasApiKey: false, hasExtraHeaders: false }; + if (providerOptions.length) status.providerOptions = providerOptions; + return status; } const hasApiKey = !!(requestConfig && typeof requestConfig.apiKey === 'string' && requestConfig.apiKey.trim()); const hasExtraHeaders = !!(requestConfig @@ -16280,7 +16912,7 @@ function buildTaskOpenAiChatStatus() { && !Array.isArray(requestConfig.extraHeaders) && Object.keys(requestConfig.extraHeaders).length > 0); const hasAuthMaterial = hasApiKey || hasExtraHeaders; - return { + const status = { ok: true, ready: hasAuthMaterial, error: hasAuthMaterial ? '' : `OpenAI Chat 提供商 ${requestConfig.providerName || ''} 缺少 API key 或额外 headers`, @@ -16290,6 +16922,8 @@ function buildTaskOpenAiChatStatus() { hasApiKey, hasExtraHeaders }; + if (providerOptions.length) status.providerOptions = providerOptions; + return status; } function postOpenAiChatCompletion(requestConfig, body, options = {}) { @@ -16529,37 +17163,29 @@ function materializeOpenAiChatTaskArtifacts(text, options = {}) { } async function runOpenAiChatTaskNode(node, context = {}) { - const requestConfig = resolveTaskOpenAiChatConfig(); - if (requestConfig.error) { - return { - success: false, - error: requestConfig.error, - summary: requestConfig.error, - output: null, - logs: [{ at: toIsoTime(Date.now()), level: 'error', message: requestConfig.error }] - }; - } - const hasApiKey = typeof requestConfig.apiKey === 'string' && requestConfig.apiKey.trim(); - const hasExtraHeaders = requestConfig.extraHeaders - && typeof requestConfig.extraHeaders === 'object' - && !Array.isArray(requestConfig.extraHeaders) - && Object.keys(requestConfig.extraHeaders).length > 0; - if (!hasApiKey && !hasExtraHeaders) { - const error = `OpenAI Chat 提供商 ${requestConfig.providerName || ''} 缺少 API key 或额外 headers`; + const candidatesResult = listTaskOpenAiChatConfigs(); + const allCandidates = Array.isArray(candidatesResult.configs) ? candidatesResult.configs : []; + const requestCandidates = allCandidates.filter(taskOpenAiChatProviderHasAuth); + if (requestCandidates.length === 0) { + const firstConfig = allCandidates[0] || {}; + const error = allCandidates.length > 0 + ? `OpenAI Chat 提供商 ${firstConfig.providerName || candidatesResult.primaryName || ''} 缺少 API key 或额外 headers` + : ((candidatesResult.errors && candidatesResult.errors[0]) || '未找到可用 OpenAI Chat 提供商'); return { success: false, error, summary: error, - output: { - provider: requestConfig.providerName || '', - model: requestConfig.model || '', - endpoint: redactTaskEndpointUrl(requestConfig.endpointUrl || ''), + output: allCandidates.length > 0 ? { + provider: firstConfig.providerName || '', + model: firstConfig.model || '', + endpoint: redactTaskEndpointUrl(firstConfig.endpointUrl || ''), status: 0, text: '', response: null, durationMs: 0, - materializedFiles: [] - }, + materializedFiles: [], + attemptedProviders: allCandidates.map((item) => item.providerName).filter(Boolean) + } : null, logs: [{ at: toIsoTime(Date.now()), level: 'error', message: error }] }; } @@ -16607,25 +17233,57 @@ async function runOpenAiChatTaskNode(node, context = {}) { workspaceContext ].filter(Boolean).join('\n'); const startedAt = Date.now(); - const body = { - model: requestConfig.model, - messages: [ - { role: 'system', content: systemPrompt }, - ...historyMessages.map((message) => ({ - role: message.role === 'assistant' ? 'assistant' : 'user', - content: String(message.content || '') - })), - { role: 'user', content: currentUserMessage } - ], - temperature: Number.isFinite(requestConfig.temperature) ? requestConfig.temperature : 0.2, - stream: false - }; - const result = await postOpenAiChatCompletion(requestConfig, body, { - registerAbort: context.registerAbort - }); - const text = result.ok ? extractModelResponseText(result.payload) : ''; - const success = result.ok && !!text; - const errorMessage = success ? '' : (result.error || 'OpenAI Chat response did not contain text'); + const messages = [ + { role: 'system', content: systemPrompt }, + ...historyMessages.map((message) => ({ + role: message.role === 'assistant' ? 'assistant' : 'user', + content: String(message.content || '') + })), + { role: 'user', content: currentUserMessage } + ]; + const attemptLogs = []; + const attemptedProviders = []; + let requestConfig = requestCandidates[0]; + let result = { ok: false, status: 0, error: 'OpenAI Chat request was not attempted', payload: null, body: '' }; + let text = ''; + let success = false; + let errorMessage = ''; + for (let index = 0; index < requestCandidates.length; index += 1) { + const candidate = requestCandidates[index]; + attemptedProviders.push(candidate.providerName || ''); + const body = { + model: candidate.model, + messages, + temperature: Number.isFinite(candidate.temperature) ? candidate.temperature : 0.2, + stream: false + }; + const attemptResult = await postOpenAiChatCompletion(candidate, body, { + registerAbort: context.registerAbort + }); + const attemptText = attemptResult.ok ? extractModelResponseText(attemptResult.payload) : ''; + const attemptSuccess = attemptResult.ok && !!attemptText; + const attemptError = attemptSuccess ? '' : (attemptResult.error || 'OpenAI Chat response did not contain text'); + attemptLogs.push({ + at: toIsoTime(Date.now()), + level: attemptSuccess ? 'info' : 'warn', + message: `OpenAI Chat request provider=${candidate.providerName} model=${candidate.model} status=${attemptResult.status || 0}${attemptError ? ` error=${truncateTaskText(attemptError, 180)}` : ''}` + }); + requestConfig = candidate; + result = attemptResult; + text = attemptText; + success = attemptSuccess; + errorMessage = attemptError; + if (attemptSuccess) { + break; + } + if (index < requestCandidates.length - 1) { + attemptLogs.push({ + at: toIsoTime(Date.now()), + level: 'warn', + message: `OpenAI Chat provider fallback: ${candidate.providerName} -> ${requestCandidates[index + 1].providerName}` + }); + } + } const summary = truncateTaskText(text || errorMessage, 400); const safeEndpoint = redactTaskEndpointUrl(requestConfig.endpointUrl); const materialized = success && allowWrite @@ -16657,15 +17315,16 @@ async function runOpenAiChatTaskNode(node, context = {}) { text, response: result.payload || null, durationMs: Date.now() - startedAt, - messageCount: body.messages.length, + messageCount: messages.length, threadId, threadStore: threadAppend && threadAppend.file ? threadAppend.file : '', materializedFiles: materialized.files, - workspaceFiles: workspaceOperations.files + workspaceFiles: workspaceOperations.files, + attemptedProviders: attemptedProviders.filter(Boolean) }, logs: [ - { at: toIsoTime(Date.now()), level: 'info', message: `OpenAI Chat request provider=${requestConfig.providerName} model=${requestConfig.model} status=${result.status || 0}` }, - ...(threadId ? [{ at: toIsoTime(Date.now()), level: 'info', message: `workspace chat thread=${threadId} messages=${body.messages.length}` }] : []), + ...attemptLogs, + ...(threadId ? [{ at: toIsoTime(Date.now()), level: 'info', message: `workspace chat thread=${threadId} messages=${messages.length}` }] : []), ...(success ? [{ at: toIsoTime(Date.now()), level: 'info', message: truncateTaskText(text, 1200) }] : [{ at: toIsoTime(Date.now()), level: 'error', message: errorMessage }]), ...materialized.files.map((file) => ({ at: toIsoTime(Date.now()), level: 'info', message: `materialized artifact ${file.relativePath} (${file.bytes} bytes)` })), ...workspaceOperations.files.map((file) => ({ at: toIsoTime(Date.now()), level: 'info', message: `workspace ${file.operation} ${file.relativePath} (${file.bytes} bytes)` })), diff --git a/cli/builtin-proxy.js b/cli/builtin-proxy.js index 157e79c3..bb2f7bd8 100644 --- a/cli/builtin-proxy.js +++ b/cli/builtin-proxy.js @@ -176,6 +176,40 @@ function createBuiltinProxyRuntimeController(deps = {}) { return false; } + function isLoopbackHostname(hostname) { + const value = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, ''); + return value === 'localhost' || value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1'; + } + + function describeLoopbackHttpsDefaultPortTarget(targetUrl) { + try { + const parsed = new URL(targetUrl); + if (parsed.protocol !== 'https:' || !isLoopbackHostname(parsed.hostname)) return ''; + if (parsed.port && parsed.port !== '443') return ''; + parsed.username = ''; + parsed.password = ''; + return `Refusing loopback HTTPS default-port upstream (${parsed.toString()}); use the real upstream URL, or http://127.0.0.1:/v1 for a local HTTP upstream.`; + } catch (_) { + return ''; + } + } + + function isLikelyHtmlBody(bodyText) { + const text = String(bodyText || '').trimStart(); + return /^= 400 ? streamedResponses.status : 502; res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(streamedResponses.bodyText || JSON.stringify({ error: streamedResponses.error || 'proxy request failed' })); @@ -2227,6 +2271,10 @@ function createBuiltinProxyRuntimeController(deps = {}) { }); if (!streamed.ok) { if (!res.headersSent) { + if (isHtmlUpstreamResult(streamed)) { + sendHtmlUpstreamDiagnostic(res); + return; + } res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'proxy request failed' })); } else if (!res.writableEnded) { @@ -2272,6 +2320,10 @@ function createBuiltinProxyRuntimeController(deps = {}) { if (upstreamResponses.ok && upstreamResponses.status >= 400) { if (!shouldFallbackFromUpstreamResponses(upstreamResponses.status, upstreamResponses.bodyText)) { + if (isHtmlUpstreamResult(upstreamResponses)) { + sendHtmlUpstreamDiagnostic(res); + return; + } res.writeHead(upstreamResponses.status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(upstreamResponses.bodyText || JSON.stringify({ error: 'Upstream error' })); return; @@ -2302,6 +2354,10 @@ function createBuiltinProxyRuntimeController(deps = {}) { } if (upstreamChat.status >= 400) { + if (isHtmlUpstreamResult(upstreamChat)) { + sendHtmlUpstreamDiagnostic(res); + return; + } res.writeHead(upstreamChat.status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(upstreamChat.bodyText || JSON.stringify({ error: 'Upstream error' })); return; @@ -2373,6 +2429,17 @@ function createBuiltinProxyRuntimeController(deps = {}) { requestHeaders['x-forwarded-for'] = req.socket.remoteAddress; } + const loopbackIssue = describeLoopbackHttpsDefaultPortTarget(targetUrl.toString()); + if (loopbackIssue) { + const body = JSON.stringify({ error: loopbackIssue }); + res.writeHead(502, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body, 'utf-8') + }); + res.end(body, 'utf-8'); + return; + } + const transport = targetUrl.protocol === 'https:' ? https : http; const upstreamReq = transport.request({ protocol: targetUrl.protocol, diff --git a/cli/local-bridge.js b/cli/local-bridge.js index 8d7ce140..0e635363 100644 --- a/cli/local-bridge.js +++ b/cli/local-bridge.js @@ -35,6 +35,7 @@ const CLAUDE_LOCAL_PROVIDER_NAME = 'claude-local'; const CLAUDE_LOCAL_EXCLUDED_KEY = 'claudeLocalExcluded'; const CIRCUIT_BREAKER_THRESHOLD = 3; const CIRCUIT_BREAKER_COOLDOWN_MS = 5 * 60 * 1000; +const LOCAL_BRIDGE_IGNORED_ERROR_RETRY_DELAYS_MS = [200, 600, 1200]; function normalizeClaudeLocalTargetApi(value) { const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''; @@ -49,6 +50,63 @@ function normalizeClaudeLocalSuffix(pathname) { return suffix.replace(/^v1\/?/, ''); } +function normalizeCodexLocalSuffix(pathname) { + let suffix = String(pathname || '').replace(/^\/bridge\/local\/?/, '').replace(/^\/+/, ''); + if (!suffix || suffix === 'v1') return ''; + while (suffix === 'v1' || suffix.startsWith('v1/')) { + suffix = suffix.replace(/^v1\/?/, ''); + if (!suffix) return ''; + } + return suffix; +} + +function isCodexLocalBridgeSuffix(suffix) { + const normalized = normalizeCodexLocalSuffix(`/bridge/local/${suffix || ''}`); + return !normalized + || normalized === 'responses' + || normalized === 'chat/completions' + || normalized === 'models' + || normalized.startsWith('models?') + || normalized.startsWith('images/') + || normalized.startsWith('audio/') + || normalized.startsWith('embeddings'); +} + + +function isLoopbackHost(hostname) { + const value = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, ''); + return value === 'localhost' || value === '127.0.0.1' || value === '::1'; +} + +function isLoopbackHttpsDefaultPortUrl(rawUrl) { + const raw = String(rawUrl || '').trim(); + if (!raw) return false; + try { + const parsed = new URL(raw); + if (parsed.protocol !== 'https:' || !isLoopbackHost(parsed.hostname)) return false; + // URL.port is empty for the implicit HTTPS default and for explicit :443. + // For this local gateway an HTTPS loopback endpoint on 443 is almost + // always a browser-origin copy/paste mistake, and it causes opaque + // ECONNREFUSED 127.0.0.1:443 failures. Require an explicit non-default + // port for loopback HTTPS, or use http://127.0.0.1:. + return !parsed.port || parsed.port === '443'; + } catch (_) { + return false; + } +} + +function describeLoopbackHttpsDefaultPortUrl(name, rawUrl) { + if (!isLoopbackHttpsDefaultPortUrl(rawUrl)) return ''; + let endpoint = String(rawUrl || '').trim(); + try { + const parsed = new URL(endpoint); + parsed.username = ''; + parsed.password = ''; + endpoint = parsed.toString(); + } catch (_) {} + return `Provider ${name} points at loopback HTTPS default port (${endpoint}); use the real upstream URL, or http://127.0.0.1:/v1 for a local HTTP upstream.`; +} + function joinClaudeLocalUpstreamUrl(baseUrl, pathSuffix) { const suffix = typeof pathSuffix === 'string' ? pathSuffix.replace(/^\/+/, '') : ''; if (suffix === 'api/tags' || suffix === 'api/chat') { @@ -87,7 +145,11 @@ function buildUpstreamPool(readConfigFn, openaiBridgeFile, excludedProviders) { try { config = readConfigFn(); } catch (e) { return { error: '读取配置失败' }; } const providers = (config && typeof config.model_providers === 'object' && !Array.isArray(config.model_providers)) ? config.model_providers : {}; + const primaryName = typeof config.model_provider === 'string' && config.model_provider.trim() + ? config.model_provider.trim() + : (typeof config.selectedProvider === 'string' && config.selectedProvider.trim() ? config.selectedProvider.trim() : ''); const pool = []; + const skippedReasons = []; const excludedSet = new Set( (Array.isArray(excludedProviders) ? excludedProviders : []) .filter(n => typeof n === 'string' && n.trim()) @@ -101,12 +163,363 @@ function buildUpstreamPool(readConfigFn, openaiBridgeFile, excludedProviders) { const bridge = typeof p.codexmate_bridge === 'string' ? p.codexmate_bridge.trim() : ''; if (bridge === 'local') continue; // avoid loop: local→local const baseUrl = typeof p.base_url === 'string' ? p.base_url.trim() : ''; - if (!isValidHttpUrl(normalizeBaseUrl(baseUrl))) continue; + if (!isValidHttpUrl(normalizeBaseUrl(baseUrl))) { + skippedReasons.push(`Provider ${name} has an invalid upstream URL`); + continue; + } + const loopbackIssue = describeLoopbackHttpsDefaultPortUrl(name, normalizeBaseUrl(baseUrl)); + if (loopbackIssue) { + skippedReasons.push(loopbackIssue); + continue; + } const authMethod = typeof p.preferred_auth_method === 'string' ? p.preferred_auth_method.trim() : ''; - pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), authMethod, requiresOpenaiAuth: !!p.requires_openai_auth }); + const models = Array.isArray(p.models) ? p.models.filter(item => typeof item === 'string' && item.trim()).map(item => item.trim()) : []; + const model = typeof p.selected_model === 'string' && p.selected_model.trim() ? p.selected_model.trim() : (models[0] || ''); + const temperature = typeof p.temperature === 'number' && Number.isFinite(p.temperature) ? p.temperature : null; + const ignoreUpstreamErrors = p.ignore_upstream_errors === true || p.ignoreUpstreamErrors === true; + pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), authMethod, requiresOpenaiAuth: !!p.requires_openai_auth, model, temperature, ignoreUpstreamErrors }); } - if (pool.length === 0) return { error: '请先添加上游 provider' }; - return { pool }; + if (pool.length === 0) { + return { error: skippedReasons.length ? skippedReasons.join('; ') : '请先添加上游 provider' }; + } + return { pool, primaryName }; +} + +function shouldRetryIgnoredLocalBridgeError(result) { + if (!result) return false; + const status = Number(result.status || 0); + if (status > 0) return status === 429 || status >= 500; + if (result.ok) return false; + return true; +} + +async function retryLocalBridgeUpstreamRequest(entry, executor) { + if (!entry || entry.ignoreUpstreamErrors !== true) { + return retryTransientRequest(executor); + } + let lastResult = null; + for (let attempt = 0; attempt <= LOCAL_BRIDGE_IGNORED_ERROR_RETRY_DELAYS_MS.length; attempt += 1) { + if (attempt > 0) { + const delay = LOCAL_BRIDGE_IGNORED_ERROR_RETRY_DELAYS_MS[attempt - 1]; + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + const timer = setTimeout(resolve, delay); + if (typeof timer.unref === 'function') timer.unref(); + }); + } + // eslint-disable-next-line no-await-in-loop + const result = await executor(attempt); + lastResult = result; + if (!shouldRetryIgnoredLocalBridgeError(result)) return result; + } + return lastResult; +} + +function localBridgeChatContentToText(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content.map((item) => { + if (typeof item === 'string') return item; + if (!item || typeof item !== 'object') return ''; + if (typeof item.text === 'string') return item.text; + if (typeof item.content === 'string') return item.content; + return ''; + }).join(''); +} + +function localBridgeResponsesContentToText(payload) { + if (!payload || typeof payload !== 'object') return ''; + if (typeof payload.output_text === 'string') return payload.output_text; + if (!Array.isArray(payload.output)) return ''; + const parts = []; + const visit = (node) => { + if (!node) return; + if (typeof node === 'string') { + parts.push(node); + return; + } + if (Array.isArray(node)) { + node.forEach(visit); + return; + } + if (typeof node !== 'object') return; + if (typeof node.text === 'string') parts.push(node.text); + else if (typeof node.output_text === 'string') parts.push(node.output_text); + else if (typeof node.content === 'string') parts.push(node.content); + if (Array.isArray(node.content)) visit(node.content); + }; + visit(payload.output); + return parts.join(''); +} + +function localBridgeGenericContentToText(payload) { + if (!payload || typeof payload !== 'object') return ''; + const directKeys = ['text', 'content', 'message', 'response', 'output']; + for (const key of directKeys) { + if (typeof payload[key] === 'string' && payload[key]) return payload[key]; + } + if (payload.error) { + if (typeof payload.error === 'string') return payload.error; + if (typeof payload.error === 'object') { + if (typeof payload.error.message === 'string') return payload.error.message; + if (typeof payload.error.code === 'string') return payload.error.code; + } + } + try { + return JSON.stringify(payload); + } catch (_) { + return ''; + } +} + + +function getLocalBridgeHeader(headers, name) { + const source = headers && typeof headers === 'object' ? headers : {}; + const target = String(name || '').toLowerCase(); + for (const [key, value] of Object.entries(source)) { + if (String(key || '').toLowerCase() === target) return String(value || ''); + } + return ''; +} + +function isLikelyHtmlBody(bodyText) { + const text = String(bodyText || '').trimStart(); + return /^ redactLocalBridgeUrl(url)) + .filter(Boolean); + return redacted.length ? ` Tried upstream URLs: ${redacted.join(', ')}` : ''; +} + +function describeInvalidChatCompletionsResponse(result) { + const attempted = describeOpenAiCompatibleAttemptedUrls(result); + if (isHtmlUpstreamResponse(result)) { + return `Upstream returned HTML instead of OpenAI chat completions JSON; check the provider base URL/path because it may point at an admin dashboard instead of /v1.${attempted}`; + } + return `Upstream returned a non-OpenAI chat completions response.${attempted}`; +} + +function redactLocalBridgeUrl(rawUrl) { + let endpoint = String(rawUrl || '').trim(); + if (!endpoint) return ''; + try { + const parsed = new URL(endpoint); + parsed.username = ''; + parsed.password = ''; + endpoint = parsed.toString(); + } catch (_) {} + return endpoint; +} + +function compactUpstreamError(error) { + return String(error || 'unknown error').replace(/\s+/g, ' ').trim().slice(0, 500) || 'unknown error'; +} + +function isHttpsTlsProtocolFailure(entry, result) { + if (!entry || !result || result.ok) return false; + let protocol = ''; + try { protocol = new URL(entry.baseUrl || '').protocol; } catch (_) {} + if (protocol !== 'https:') return false; + const error = compactUpstreamError(result.error); + return /SSL routines|wrong version number|EPROTO|ssl3_get_record|packet length too long|tls handshake/i.test(error); +} + +function buildUpstreamRequestFailure(entry, result) { + const cause = compactUpstreamError(result && result.error); + if (isHttpsTlsProtocolFailure(entry, result)) { + const endpoint = redactLocalBridgeUrl(entry && entry.baseUrl); + const name = entry && entry.name ? entry.name : 'upstream'; + return { + status: 400, + body: { + error: `Provider ${name} failed TLS/HTTPS handshake${endpoint ? ` (${endpoint})` : ''}; if this upstream is an HTTP service such as local New API, use http://.../v1 instead of https://.../v1.`, + cause + } + }; + } + return { + status: 502, + body: { error: `Upstream request failed: ${cause}` } + }; +} + +function sendUpstreamRequestFailure(res, entry, result, headers = {}) { + const failure = buildUpstreamRequestFailure(entry, result); + res.writeHead(failure.status, { 'Content-Type': 'application/json; charset=utf-8', ...headers }); + res.end(JSON.stringify(failure.body)); +} + +function buildOpenAiCompatibleUpstreamUrlCandidates(baseUrl, pathSuffix) { + const safeSuffix = String(pathSuffix || '').replace(/^\/+/, ''); + const candidates = []; + const push = (url) => { + if (url && !candidates.includes(url)) candidates.push(url); + }; + push(joinApiUrl(baseUrl, safeSuffix)); + const trimmed = normalizeBaseUrl(baseUrl); + if (trimmed && safeSuffix) push(`${trimmed}/${safeSuffix}`); + let root = trimmed; + if (trimmed && safeSuffix && /\/v1$/i.test(trimmed)) { + root = trimmed.replace(/\/v1$/i, ''); + push(`${root}/${safeSuffix}`); + } + if (root && safeSuffix === 'chat/completions') { + [ + 'compatible/v1/chat/completions', + 'api/v1/chat/completions', + 'openai/v1/chat/completions', + 'api/openai/v1/chat/completions' + ].forEach((candidateSuffix) => push(`${root}/${candidateSuffix}`)); + } + return candidates; +} + +function shellQuoteForLocalBridgeCurl(value) { + return `'${String(value || '').replace(/'/g, `'"'"'`)}'`; +} + +function buildOpenAiCompatibleCurlCommand(targetUrl, options = {}) { + const method = String(options.method || 'GET').toUpperCase(); + const bodyText = options.body ? JSON.stringify(options.body) : ''; + const headers = { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.headers || {}) + }; + const hasUserAgent = Object.keys(headers).some((key) => String(key || '').toLowerCase() === 'user-agent'); + if (!hasUserAgent) headers['User-Agent'] = 'codexmate-local-bridge/0.0.56'; + const parts = ['curl', '-i', '-sS', '-X', shellQuoteForLocalBridgeCurl(method), shellQuoteForLocalBridgeCurl(targetUrl)]; + Object.entries(headers).forEach(([name, value]) => { + if (value === undefined || value === null || value === '') return; + if (String(name).toLowerCase() === 'content-length') return; + parts.push('-H', shellQuoteForLocalBridgeCurl(`${name}: ${String(value)}`)); + }); + if (bodyText) { + parts.push('--data-raw', shellQuoteForLocalBridgeCurl(bodyText)); + } + return parts.join(' '); +} + +function isOpenAiCompatibleStreamingRouteFailure(result) { + const text = String(result && result.bodyText || '').trim(); + if (!text) return false; + return /Streaming response failed/i.test(text); +} + +function shouldTryNextOpenAiCompatibleUrl(result) { + if (!result || !result.ok) return false; + const status = Number(result.status || 0); + return status === 404 || status === 405 || isHtmlUpstreamResponse(result) || isOpenAiCompatibleStreamingRouteFailure(result); +} + +async function proxyOpenAiCompatibleJson(entry, baseUrl, pathSuffix, options = {}) { + const urls = buildOpenAiCompatibleUpstreamUrlCandidates(baseUrl, pathSuffix); + if (!urls.length) return { ok: false, error: 'failed to build upstream URL' }; + let lastResult = null; + for (let index = 0; index < urls.length; index += 1) { + if (typeof options.onUpstreamCurl === 'function') { + try { options.onUpstreamCurl(buildOpenAiCompatibleCurlCommand(urls[index], options)); } catch (_) { } + } + const result = await retryLocalBridgeUpstreamRequest(entry, () => proxyRequestJson(urls[index], options)); + if (result && typeof result === 'object') result.attemptedUrls = urls.slice(0, index + 1); + lastResult = result; + if (!result || !result.ok) return result; + if (index < urls.length - 1 && shouldTryNextOpenAiCompatibleUrl(result)) continue; + return result; + } + return lastResult || { ok: false, error: 'failed to build upstream URL' }; +} + +function isLocalBridgeSseBody(result) { + const contentType = getLocalBridgeHeader(result && result.headers, 'content-type').toLowerCase(); + return contentType.includes('text/event-stream') || String(result && result.bodyText || '').trim().startsWith('data:'); +} + +function buildChatCompletionsSseFromJson(bodyText) { + const parsed = parseJsonOrError(bodyText || ''); + if (parsed.error || !parsed.value || typeof parsed.value !== 'object') return ''; + const payload = parsed.value; + const id = typeof payload.id === 'string' && payload.id ? payload.id : `chatcmpl_${Date.now()}`; + const model = typeof payload.model === 'string' ? payload.model : ''; + const created = Number.isFinite(payload.created) ? payload.created : Math.floor(Date.now() / 1000); + const lines = []; + if (!Array.isArray(payload.choices)) { + const content = localBridgeResponsesContentToText(payload) || localBridgeGenericContentToText(payload); + if (!content) return ''; + lines.push(`data: ${JSON.stringify({ + id: id.startsWith('chatcmpl') ? id : `chatcmpl_${id}`, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index: 0, delta: { role: 'assistant', content }, finish_reason: null }] + })}\n\n`); + lines.push(`data: ${JSON.stringify({ + id: id.startsWith('chatcmpl') ? id : `chatcmpl_${id}`, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] + })}\n\n`); + lines.push('data: [DONE]\n\n'); + return lines.join(''); + } + payload.choices.forEach((choice, fallbackIndex) => { + const index = Number.isFinite(choice && choice.index) ? choice.index : fallbackIndex; + const message = choice && choice.message && typeof choice.message === 'object' ? choice.message : {}; + const delta = { role: typeof message.role === 'string' && message.role ? message.role : 'assistant' }; + const content = localBridgeChatContentToText(message.content); + if (content) delta.content = content; + if (Array.isArray(message.tool_calls) && message.tool_calls.length) delta.tool_calls = message.tool_calls; + lines.push(`data: ${JSON.stringify({ + id, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index, delta, finish_reason: null }] + })}\n\n`); + lines.push(`data: ${JSON.stringify({ + id, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index, delta: {}, finish_reason: choice && choice.finish_reason ? choice.finish_reason : 'stop' }] + })}\n\n`); + }); + lines.push('data: [DONE]\n\n'); + return lines.join(''); +} + +function buildChatCompletionsSseFromText(bodyText) { + const content = String(bodyText || '').trim(); + if (!content || isLikelyHtmlBody(content)) return ''; + const id = `chatcmpl_${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + return [ + `data: ${JSON.stringify({ + id, + object: 'chat.completion.chunk', + created, + model: '', + choices: [{ index: 0, delta: { role: 'assistant', content }, finish_reason: null }] + })}\n\n`, + `data: ${JSON.stringify({ + id, + object: 'chat.completion.chunk', + created, + model: '', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] + })}\n\n`, + 'data: [DONE]\n\n' + ].join(''); } function buildClaudeUpstreamPool(claudeProvidersFile, excludedProviders) { @@ -118,6 +531,7 @@ function buildClaudeUpstreamPool(claudeProvidersFile, excludedProviders) { const providers = (raw && typeof raw.providers === 'object' && !Array.isArray(raw.providers)) ? raw.providers : {}; const pool = []; + const skippedReasons = []; const excludedSet = new Set( (Array.isArray(excludedProviders) ? excludedProviders : []) .filter(n => typeof n === 'string' && n.trim()) @@ -127,23 +541,31 @@ function buildClaudeUpstreamPool(claudeProvidersFile, excludedProviders) { if (!p || typeof p !== 'object') continue; if (excludedSet.has(name.toLowerCase())) continue; const baseUrl = typeof p.baseUrl === 'string' ? p.baseUrl.trim() : ''; - if (!baseUrl || !isValidHttpUrl(normalizeBaseUrl(baseUrl))) continue; + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + if (!baseUrl || !isValidHttpUrl(normalizedBaseUrl)) { + skippedReasons.push(`Provider ${name} has an invalid upstream URL`); + continue; + } + const loopbackIssue = describeLoopbackHttpsDefaultPortUrl(name, normalizedBaseUrl); + if (loopbackIssue) { + skippedReasons.push(loopbackIssue); + continue; + } pool.push({ name, - baseUrl: normalizeBaseUrl(baseUrl), + baseUrl: normalizedBaseUrl, apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', model: typeof p.model === 'string' ? p.model.trim() : '', targetApi: normalizeClaudeLocalTargetApi(p.targetApi) }); } - if (pool.length === 0) return { error: '请先添加可用的 Claude 上游提供商' }; + if (pool.length === 0) return { error: skippedReasons.length ? skippedReasons.join('; ') : '请先添加可用的 Claude 上游提供商' }; return { pool }; } function resolveUpstreamAuth(entry, openaiBridgeFile, reqAuthToken) { - if (entry.authMethod === 'codexmate' || entry.requiresOpenaiAuth) { - const token = reqAuthToken || ''; - return token ? (token.startsWith('sk-') ? `Bearer ${token}` : `Bearer ${token}`) : ''; + if (entry.authMethod === 'codexmate') { + return ''; } if (entry.authMethod === 'openai-bridge') { const settings = readOpenaiBridgeSettings(openaiBridgeFile); @@ -151,8 +573,72 @@ function resolveUpstreamAuth(entry, openaiBridgeFile, reqAuthToken) { if (upstream && upstream.apiKey) { return upstream.apiKey.startsWith('Bearer ') ? upstream.apiKey : `Bearer ${upstream.apiKey}`; } + return ''; } - return ''; + const providerToken = typeof entry.authMethod === 'string' ? entry.authMethod.trim() : ''; + if (!providerToken) { + return ''; + } + return /^Bearer\s+/i.test(providerToken) ? providerToken : `Bearer ${providerToken}`; +} + +function describeUpstreamAuthSource(entry) { + if (!entry || typeof entry !== 'object') return 'none'; + if (entry.authMethod === 'codexmate') return 'missing provider key (codexmate sentinel ignored)'; + if (entry.authMethod === 'openai-bridge') return 'openai bridge provider key'; + const providerToken = typeof entry.authMethod === 'string' ? entry.authMethod.trim() : ''; + if (providerToken) return 'provider key'; + if (entry.requiresOpenaiAuth) return 'missing provider key'; + return 'none'; +} + +function appendLocalBridgeLogValue(currentValue, nextValue) { + const current = String(currentValue || ''); + const next = String(nextValue || ''); + if (!next) return current; + const parts = current ? current.split(/\s*,\s*/g).filter(Boolean) : []; + if (!parts.includes(next)) parts.push(next); + return parts.join(', '); +} + +function summarizeLocalBridgeRequestBody(bodyText) { + const raw = typeof bodyText === 'string' ? bodyText : ''; + const trimmed = raw.trim(); + if (!trimmed) return { model: '', summary: '', preview: '' }; + const redactText = (value) => String(value || '') + .replace(/("(?:api[_-]?key|token|authorization|password|secret)"\s*:\s*")[^"]*(")/ig, '$1***$2') + .replace(/(Bearer\s+)[A-Za-z0-9._~+\-/=]+/ig, '$1***') + .replace(/\bsk-[A-Za-z0-9._-]+\b/g, 'sk-***'); + try { + const payload = JSON.parse(raw); + const model = typeof payload.model === 'string' ? payload.model : ''; + const summaryParts = []; + if (model) summaryParts.push(`model=${model}`); + if (payload.max_tokens !== undefined) summaryParts.push(`max_tokens=${payload.max_tokens}`); + if (payload.temperature !== undefined) summaryParts.push(`temperature=${payload.temperature}`); + if (Array.isArray(payload.messages)) { + const roles = payload.messages + .map((item) => item && typeof item.role === 'string' ? item.role : '') + .filter(Boolean) + .join(','); + summaryParts.push(`${payload.messages.length} message(s)${roles ? ` [${roles}]` : ''}`); + const first = payload.messages.find((item) => item && typeof item.content === 'string' && item.content.trim()); + if (first) summaryParts.push(`${String(first.role || 'message')}: ${first.content.trim().slice(0, 160)}`); + } else if (typeof payload.input === 'string') { + summaryParts.push(`input: ${payload.input.trim().slice(0, 160)}`); + } + const pretty = JSON.stringify(payload, null, 2); + return { model, summary: summaryParts.join(' · '), preview: redactText(pretty).slice(0, 4000) }; + } catch (_) { + return { model: '', summary: '', preview: redactText(raw).slice(0, 4000) }; + } +} + +function redactLocalBridgePreviewText(value) { + return String(value || '') + .replace(/("(?:api[_-]?key|token|authorization|password|secret)"\s*:\s*")[^"]*(")/ig, '$1***$2') + .replace(/(Bearer\s+)[A-Za-z0-9._~+\-/=]+/ig, '$1***') + .replace(/\bsk-[A-Za-z0-9._-]+\b/g, 'sk-***'); } function createLocalBridgeHttpHandler(options = {}) { @@ -163,6 +649,7 @@ function createLocalBridgeHttpHandler(options = {}) { const maxBodySize = Number.isFinite(options.maxBodySize) ? options.maxBodySize : 0; const httpAgent = options.httpAgent; const httpsAgent = options.httpsAgent; + const onRequestLog = typeof options.onRequestLog === 'function' ? options.onRequestLog : null; const maxUpstreamBytes = Number.isFinite(options.maxUpstreamBytes) && options.maxUpstreamBytes > 0 ? Math.floor(options.maxUpstreamBytes) : Math.max(16 * 1024 * 1024, maxBodySize > 0 ? maxBodySize * 4 : 0); @@ -172,8 +659,115 @@ function createLocalBridgeHttpHandler(options = {}) { const circuitState = new Map(); // name → { failures, openUntil } let rrIndex = 0; - function pickUpstream(pool) { + function installRequestLogger(req, res, parsedUrl, suffix) { + if (!onRequestLog) return null; + const startedAt = Date.now(); + const item = { + id: `${startedAt}-${Math.random().toString(36).slice(2, 8)}`, + at: new Date(startedAt).toISOString(), + method: (req.method || 'GET').toUpperCase(), + path: `${parsedUrl.pathname || ''}${parsedUrl.search || ''}`, + suffix: String(suffix || ''), + provider: '', + upstreamProvider: '', + attemptedProviders: '', + status: 0, + durationMs: 0, + model: '', + summary: '', + bodyPreview: '', + upstreamCurlPreview: '', + upstreamAuthSource: '', + responseContentType: '', + responsePreview: '', + error: '', + requestState: 'pending' + }; + try { onRequestLog({ ...item }); } catch (_) { } + let finished = false; + let capturedResponseBytes = 0; + const capturedResponseChunks = []; + const captureResponseChunk = (chunk) => { + if (chunk === undefined || chunk === null || capturedResponseBytes >= 4000) return; + let buffer; + try { + buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + } catch (_) { + return; + } + if (!buffer.length) return; + const remaining = Math.max(0, 4000 - capturedResponseBytes); + const slice = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer; + capturedResponseChunks.push(slice); + capturedResponseBytes += slice.length; + }; + const originalWriteHead = res.writeHead; + const originalWrite = res.write; + const originalEnd = res.end; + res.writeHead = function patchedWriteHead(statusCode, reasonPhrase, headers) { + item.status = Number(statusCode) || item.status || 0; + const headerBag = headers && typeof headers === 'object' + ? headers + : (reasonPhrase && typeof reasonPhrase === 'object' ? reasonPhrase : {}); + item.upstreamProvider = String(headerBag['X-Codexmate-Upstream-Provider'] || headerBag['x-codexmate-upstream-provider'] || item.upstreamProvider || ''); + item.attemptedProviders = String(headerBag['X-Codexmate-Attempted-Providers'] || headerBag['x-codexmate-attempted-providers'] || item.attemptedProviders || ''); + item.responseContentType = String(headerBag['Content-Type'] || headerBag['content-type'] || item.responseContentType || ''); + return originalWriteHead.apply(this, arguments); + }; + res.write = function patchedWrite(chunk, encoding, callback) { + captureResponseChunk(chunk); + return originalWrite.apply(this, arguments); + }; + res.end = function patchedEnd(chunk, encoding, callback) { + if (!finished) { + finished = true; + captureResponseChunk(chunk); + item.status = item.status || res.statusCode || 0; + item.durationMs = Date.now() - startedAt; + const responseText = capturedResponseChunks.length + ? Buffer.concat(capturedResponseChunks).toString('utf-8') + : ''; + item.responsePreview = redactLocalBridgePreviewText(responseText).slice(0, 4000); + if (!item.responseContentType && typeof res.getHeader === 'function') { + item.responseContentType = String(res.getHeader('content-type') || ''); + } + if (responseText && item.status >= 400) { + try { + const parsed = JSON.parse(responseText); + item.error = typeof parsed.error === 'string' ? parsed.error.slice(0, 240) : ''; + } catch (_) { } + } + item.requestState = item.status >= 400 ? 'failed' : 'done'; + try { onRequestLog({ ...item }); } catch (_) { } + } + return originalEnd.apply(this, arguments); + }; + return item; + } + + function appendRequestLogUpstreamCurl(requestLog, curlCommand) { + if (!requestLog || !curlCommand) return; + const existing = String(requestLog.upstreamCurlPreview || ''); + const next = existing ? `${existing}\n\n${curlCommand}` : String(curlCommand); + requestLog.upstreamCurlPreview = next.slice(0, 4000); + } + + function appendRequestLogUpstreamAuthSource(requestLog, entry) { + if (!requestLog || !entry) return; + const source = `${entry.name}: ${describeUpstreamAuthSource(entry)}`; + requestLog.upstreamAuthSource = appendLocalBridgeLogValue(requestLog.upstreamAuthSource, source).slice(0, 500); + } + + function pickUpstream(pool, preferredName = '') { const now = Date.now(); + const preferred = typeof preferredName === 'string' ? preferredName.trim().toLowerCase() : ''; + if (preferred) { + const preferredEntry = pool.find((entry) => entry && entry.name.toLowerCase() === preferred); + if (preferredEntry) { + const st = circuitState.get(preferredEntry.name); + if (!st || st.openUntil <= now) return { entry: preferredEntry, idx: pool.indexOf(preferredEntry) }; + } + } for (let i = 0; i < pool.length; i++) { const idx = rrIndex++ % pool.length; const entry = pool[idx]; @@ -186,6 +780,20 @@ function createLocalBridgeHttpHandler(options = {}) { return { entry: pool[0], idx: 0 }; } + function pickUpstreamExcluding(pool, excludedNames) { + const now = Date.now(); + const excluded = excludedNames instanceof Set ? excludedNames : new Set(); + for (let i = 0; i < pool.length; i++) { + const idx = rrIndex++ % pool.length; + const entry = pool[idx]; + if (excluded.has(entry.name)) continue; + const st = circuitState.get(entry.name); + if (st && st.openUntil > now) continue; + return { entry, idx }; + } + return null; + } + function recordFailure(name) { let st = circuitState.get(name); if (!st) { st = { failures: 0, openUntil: 0 }; circuitState.set(name, st); } @@ -372,8 +980,7 @@ function createLocalBridgeHttpHandler(options = {}) { })); if (!upstreamResult.ok) { recordFailure(entry.name); - res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` })); + sendUpstreamRequestFailure(res, entry, upstreamResult); return; } const parsedModels = parseJsonOrError(upstreamResult.bodyText || '{}'); @@ -423,20 +1030,27 @@ function createLocalBridgeHttpHandler(options = {}) { res.end(JSON.stringify({ error: e && e.message ? e.message : 'Invalid request payload' })); return; } - const upstreamUrl = joinClaudeLocalUpstreamUrl(entry.baseUrl.replace(/\/+$/, ''), targetApi === 'ollama' ? 'api/chat' : 'chat/completions'); - const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, { - method: 'POST', - body: upstreamBody, - headers: buildClaudeLocalAuthHeaders(entry, token, targetApi, expectedToken), - maxBytes: maxUpstreamBytes, - httpAgent, - httpsAgent - })); + const upstreamResult = targetApi === 'ollama' + ? await retryTransientRequest(() => proxyRequestJson(joinClaudeLocalUpstreamUrl(entry.baseUrl.replace(/\/+$/, ''), 'api/chat'), { + method: 'POST', + body: upstreamBody, + headers: buildClaudeLocalAuthHeaders(entry, token, targetApi, expectedToken), + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + })) + : await proxyOpenAiCompatibleJson(entry, entry.baseUrl.replace(/\/+$/, ''), 'chat/completions', { + method: 'POST', + body: upstreamBody, + headers: buildClaudeLocalAuthHeaders(entry, token, targetApi, expectedToken), + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + }); if (!upstreamResult.ok) { recordFailure(entry.name); - res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` })); + sendUpstreamRequestFailure(res, entry, upstreamResult); return; } if (upstreamResult.status >= 400) { @@ -445,6 +1059,12 @@ function createLocalBridgeHttpHandler(options = {}) { res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' })); return; } + if (targetApi === 'chat_completions' && isHtmlUpstreamResponse(upstreamResult)) { + recordFailure(entry.name); + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: describeInvalidChatCompletionsResponse(upstreamResult) })); + return; + } const parsedUpstream = parseJsonOrError(upstreamResult.bodyText || '{}'); if (parsedUpstream.error) { res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); @@ -535,8 +1155,7 @@ function createLocalBridgeHttpHandler(options = {}) { if (!upstreamResult.ok) { recordFailure(entry.name); - res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` })); + sendUpstreamRequestFailure(res, entry, upstreamResult); return; } @@ -562,10 +1181,13 @@ function createLocalBridgeHttpHandler(options = {}) { return true; } - // Codex local bridge: /bridge/local/v1 + // Codex local bridge: /bridge/local/v1. Also tolerate maxx-style + // callers that append their own /v1/... to a configured versioned base + // URL, yielding /bridge/local/v1/v1/.... if (!pathname.startsWith('/bridge/local/')) return false; const suffix = pathname.replace(/^\/bridge\/local\/?/, ''); - if (!suffix.startsWith('v1')) return false; + if (!suffix.startsWith('v1') && !isCodexLocalBridgeSuffix(suffix)) return false; + const requestLog = installRequestLogger(req, res, parsedUrl, suffix); void (async () => { try { @@ -595,11 +1217,14 @@ function createLocalBridgeHttpHandler(options = {}) { return; } const pool = poolResult.pool; + const primaryName = poolResult.primaryName || ''; - const { entry, idx } = pickUpstream(pool); + const { entry, idx } = pickUpstream(pool, primaryName); + if (requestLog) requestLog.provider = entry.name; const authHeader = resolveUpstreamAuth(entry, openaiBridgeFile, token); + appendRequestLogUpstreamAuthSource(requestLog, entry); - const normalizedSuffix = suffix.replace(/^v1\/?/, ''); + const normalizedSuffix = normalizeCodexLocalSuffix(pathname); const upstreamBase = entry.baseUrl.replace(/\/+$/, ''); if (!normalizedSuffix) { @@ -615,6 +1240,12 @@ function createLocalBridgeHttpHandler(options = {}) { if (normalizedSuffix === 'responses' && (req.method || 'GET').toUpperCase() === 'POST') { const bodyResult = await readRequestBody(req, maxBodySize); + if (requestLog) { + const summary = summarizeLocalBridgeRequestBody(bodyResult.body || ''); + requestLog.model = summary.model; + requestLog.summary = summary.summary; + requestLog.bodyPreview = summary.preview; + } if (bodyResult.error) { res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: bodyResult.error })); @@ -628,15 +1259,15 @@ function createLocalBridgeHttpHandler(options = {}) { } const responsesRequest = parsed.value; const wantsSse = !!(responsesRequest && responsesRequest.stream); - const upstreamResponsesUrl = joinApiUrl(upstreamBase, 'responses'); - const upstreamResponsesResult = await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, { + const upstreamResponsesResult = await proxyOpenAiCompatibleJson(entry, upstreamBase, 'responses', { method: 'POST', body: responsesRequest, headers: { ...(authHeader ? { Authorization: authHeader } : {}) }, maxBytes: maxUpstreamBytes, httpAgent, - httpsAgent - })); + httpsAgent, + onUpstreamCurl: (curl) => appendRequestLogUpstreamCurl(requestLog, curl) + }); if (upstreamResponsesResult.ok && upstreamResponsesResult.status < 400) { recordSuccess(entry.name); @@ -659,16 +1290,15 @@ function createLocalBridgeHttpHandler(options = {}) { } if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 400 && !shouldFallbackFromUpstreamResponses(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) { - recordFailure(entry.name); + if (entry.ignoreUpstreamErrors !== true) recordFailure(entry.name); res.writeHead(upstreamResponsesResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' })); return; } if (!upstreamResponsesResult.ok) { - recordFailure(entry.name); - res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResponsesResult.error}` })); + if (entry.ignoreUpstreamErrors !== true) recordFailure(entry.name); + sendUpstreamRequestFailure(res, entry, upstreamResponsesResult); return; } @@ -680,18 +1310,17 @@ function createLocalBridgeHttpHandler(options = {}) { res.end(JSON.stringify({ error: converted.error })); return; } - const chatUrl = joinApiUrl(upstreamBase, 'chat/completions'); - const chatResult = await retryTransientRequest(() => proxyRequestJson(chatUrl, { + const chatResult = await proxyOpenAiCompatibleJson(entry, upstreamBase, 'chat/completions', { method: 'POST', body: converted.chat, headers: { ...(authHeader ? { Authorization: authHeader } : {}), 'Content-Type': 'application/json' }, maxBytes: maxUpstreamBytes, httpAgent, - httpsAgent - })); + httpsAgent, + onUpstreamCurl: (curl) => appendRequestLogUpstreamCurl(requestLog, curl) + }); if (!chatResult.ok) { - res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ error: `Upstream request failed: ${chatResult.error}` })); + sendUpstreamRequestFailure(res, entry, chatResult); return; } const chatJson = parseJsonOrError(chatResult.bodyText); @@ -722,25 +1351,170 @@ function createLocalBridgeHttpHandler(options = {}) { return; } - // passthrough for other v1/* paths - const upstreamUrl = joinApiUrl(upstreamBase, normalizedSuffix); - const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, { - method: req.method || 'GET', - body: null, - headers: { ...(authHeader ? { Authorization: authHeader } : {}) }, - maxBytes: maxUpstreamBytes, - httpAgent, - httpsAgent - })); - if (!upstreamResult.ok) { - recordFailure(entry.name); - res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` })); + // OpenAI-compatible passthrough for /bridge/local/v1/* paths. + // Automatic policy: retry transient errors, fail over to the next provider, + // and let the circuit breaker freeze repeatedly failing providers. + const method = (req.method || 'GET').toUpperCase(); + let passthroughBody = null; + if (method !== 'GET' && method !== 'HEAD') { + const bodyResult = await readRequestBody(req, maxBodySize); + if (requestLog) { + const summary = summarizeLocalBridgeRequestBody(bodyResult.body || ''); + requestLog.model = summary.model; + requestLog.summary = summary.summary; + requestLog.bodyPreview = summary.preview; + } + if (bodyResult.error) { + res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: bodyResult.error })); + return; + } + if (String(bodyResult.body || '').trim()) { + const parsedBody = parseJsonOrError(bodyResult.body); + if (parsedBody.error) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Invalid JSON: ${parsedBody.error}` })); + return; + } + passthroughBody = parsedBody.value; + } + } + + const attemptedProviders = []; + const attemptedProviderNames = new Set(); + let lastResult = null; + let lastEntry = null; + const clientWantsChatStream = normalizedSuffix === 'chat/completions' + && passthroughBody + && typeof passthroughBody === 'object' + && !Array.isArray(passthroughBody) + && passthroughBody.stream === true; + if (normalizedSuffix === 'models' && entry.model && (method === 'GET' || method === 'HEAD')) { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Codexmate-Upstream-Provider': entry.name, + 'X-Codexmate-Model-Mapping': `*=${entry.model}` + }); + res.end(method === 'HEAD' ? '' : JSON.stringify({ + object: 'list', + data: [{ id: entry.model, object: 'model', owned_by: entry.name }] + })); return; } - recordSuccess(entry.name); - res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(upstreamResult.bodyText); + const shouldFailover = (result) => { + if (!result || !result.ok) return true; + if (normalizedSuffix === 'chat/completions' && isHtmlUpstreamResponse(result)) return true; + const status = Number(result.status || 0); + return status === 401 || status === 403 || status === 429 || status >= 500; + }; + + for (let attempt = 0; attempt < pool.length; attempt += 1) { + const selected = attempt === 0 ? { entry, idx } : pickUpstreamExcluding(pool, attemptedProviderNames); + if (!selected || !selected.entry) break; + const attemptEntry = selected.entry; + const attemptAuthHeader = resolveUpstreamAuth(attemptEntry, openaiBridgeFile, token); + appendRequestLogUpstreamAuthSource(requestLog, attemptEntry); + const attemptBase = attemptEntry.baseUrl.replace(/\/+$/, ''); + let attemptBody = passthroughBody; + if (normalizedSuffix === 'chat/completions' && attemptBody && typeof attemptBody === 'object' && !Array.isArray(attemptBody)) { + attemptBody = { ...attemptBody }; + if (clientWantsChatStream) { + delete attemptBody.stream; + delete attemptBody.stream_options; + } + if (attemptEntry.model) { + attemptBody.model = attemptEntry.model; + } + if (attemptBody.temperature === undefined && attemptEntry.temperature !== null && attemptEntry.temperature !== undefined) { + attemptBody.temperature = attemptEntry.temperature; + } + } + attemptedProviders.push(attemptEntry.name); + attemptedProviderNames.add(attemptEntry.name); + const upstreamResult = await proxyOpenAiCompatibleJson(attemptEntry, attemptBase, normalizedSuffix, { + method, + body: attemptBody, + headers: { + ...(attemptAuthHeader ? { Authorization: attemptAuthHeader } : {}), + ...(attemptBody !== null ? { 'Content-Type': 'application/json' } : {}) + }, + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent, + onUpstreamCurl: (curl) => appendRequestLogUpstreamCurl(requestLog, curl) + }); + lastResult = upstreamResult; + lastEntry = attemptEntry; + if (!shouldFailover(upstreamResult)) { + recordSuccess(attemptEntry.name); + if (clientWantsChatStream) { + const sseBody = isLocalBridgeSseBody(upstreamResult) + ? upstreamResult.bodyText + : (isHtmlUpstreamResponse(upstreamResult) + ? '' + : (buildChatCompletionsSseFromJson(upstreamResult.bodyText) + || buildChatCompletionsSseFromText(upstreamResult.bodyText))); + if (!sseBody) { + res.writeHead(502, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Codexmate-Upstream-Provider': attemptEntry.name, + 'X-Codexmate-Attempted-Providers': attemptedProviders.join(',') + }); + res.end(JSON.stringify({ error: describeInvalidChatCompletionsResponse(upstreamResult) })); + return; + } + res.writeHead(upstreamResult.status, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + 'X-Codexmate-Upstream-Provider': attemptEntry.name, + 'X-Codexmate-Attempted-Providers': attemptedProviders.join(',') + }); + res.end(sseBody); + return; + } + if (normalizedSuffix === 'chat/completions' && isHtmlUpstreamResponse(upstreamResult)) { + res.writeHead(502, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Codexmate-Upstream-Provider': attemptEntry.name, + 'X-Codexmate-Attempted-Providers': attemptedProviders.join(',') + }); + res.end(JSON.stringify({ error: describeInvalidChatCompletionsResponse(upstreamResult) })); + return; + } + res.writeHead(upstreamResult.status, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Codexmate-Upstream-Provider': attemptEntry.name, + 'X-Codexmate-Attempted-Providers': attemptedProviders.join(',') + }); + res.end(upstreamResult.bodyText); + return; + } + if (attemptEntry.ignoreUpstreamErrors !== true) recordFailure(attemptEntry.name); + } + + if (!lastResult || !lastResult.ok) { + sendUpstreamRequestFailure(res, lastEntry, lastResult || { ok: false, error: 'unknown error' }, { + 'X-Codexmate-Attempted-Providers': attemptedProviders.join(',') + }); + return; + } + if (normalizedSuffix === 'chat/completions' && isHtmlUpstreamResponse(lastResult)) { + res.writeHead(502, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Codexmate-Upstream-Provider': lastEntry ? lastEntry.name : '', + 'X-Codexmate-Attempted-Providers': attemptedProviders.join(',') + }); + res.end(JSON.stringify({ error: describeInvalidChatCompletionsResponse(lastResult) })); + return; + } + res.writeHead(lastResult.status || 502, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Codexmate-Upstream-Provider': lastEntry ? lastEntry.name : '', + 'X-Codexmate-Attempted-Providers': attemptedProviders.join(',') + }); + res.end(lastResult.bodyText || JSON.stringify({ error: 'Upstream error' })); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' })); diff --git a/cli/openai-bridge.js b/cli/openai-bridge.js index 33f62fa4..c5e4e7f1 100644 --- a/cli/openai-bridge.js +++ b/cli/openai-bridge.js @@ -13,6 +13,7 @@ const REQUEST_TIMEOUT_MS = 5 * 60 * 1000; const RESPONSES_UNSUPPORTED_TTL_MS = 30 * 60 * 1000; const DEFAULT_CODEX_VERSION = '0.98.0'; const DEFAULT_CODEX_USER_AGENT = 'codex_cli_rs/0.98.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'; +const DEFAULT_GENERIC_UPSTREAM_USER_AGENT = 'codexmate-local-bridge/0.0.56'; const DEFAULT_CODEX_ORIGINATOR = 'codex_cli_rs'; const DEFAULT_OPENAI_BETA = 'responses=experimental'; @@ -1229,6 +1230,40 @@ function isLoopbackAddress(address) { return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1'; } +function isLoopbackHostname(hostname) { + const value = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, ''); + return value === 'localhost' || value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1'; +} + +function describeLoopbackHttpsDefaultPortTarget(targetUrl) { + try { + const parsed = new URL(targetUrl); + if (parsed.protocol !== 'https:' || !isLoopbackHostname(parsed.hostname)) return ''; + if (parsed.port && parsed.port !== '443') return ''; + parsed.username = ''; + parsed.password = ''; + return `Refusing loopback HTTPS default-port upstream (${parsed.toString()}); use the real upstream URL, or http://127.0.0.1:/v1 for a local HTTP upstream.`; + } catch (_) { + return ''; + } +} + +function isLikelyHtmlBody(bodyText) { + const text = String(bodyText || '').trimStart(); + return /^ 0 @@ -1799,6 +1836,8 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) { function streamResponsesSse(targetUrl, options = {}) { const parsed = new URL(targetUrl); + const loopbackIssue = describeLoopbackHttpsDefaultPortTarget(targetUrl); + if (loopbackIssue) return Promise.resolve({ ok: false, error: loopbackIssue }); const transport = parsed.protocol === 'https:' ? https : http; const bodyText = options.body ? JSON.stringify(options.body) : ''; const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0 @@ -1977,6 +2016,8 @@ function streamResponsesSse(targetUrl, options = {}) { async function proxyRequestJson(targetUrl, options = {}) { const parsed = new URL(targetUrl); + const loopbackIssue = describeLoopbackHttpsDefaultPortTarget(targetUrl); + if (loopbackIssue) return { ok: false, error: loopbackIssue }; const transport = parsed.protocol === 'https:' ? https : http; const bodyText = options.body ? JSON.stringify(options.body) : ''; const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0 @@ -1987,6 +2028,8 @@ async function proxyRequestJson(targetUrl, options = {}) { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers || {}) }; + const hasUserAgent = Object.keys(headers).some((key) => String(key || '').toLowerCase() === 'user-agent'); + if (!hasUserAgent) headers['User-Agent'] = DEFAULT_GENERIC_UPSTREAM_USER_AGENT; if (options.body) { headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8'); } @@ -2255,6 +2298,10 @@ function createOpenaiBridgeHttpHandler(options = {}) { : false; if (!canFallbackToChat) { if (res.writableEnded || res.destroyed) return; + if (!res.headersSent && isHtmlUpstreamResult(streamedResponses)) { + sendHtmlUpstreamDiagnostic(res); + return; + } const status = streamedResponses.status && streamedResponses.status >= 400 ? streamedResponses.status : 502; if (!res.headersSent) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); @@ -2296,6 +2343,10 @@ function createOpenaiBridgeHttpHandler(options = {}) { return; } if (!res.headersSent) { + if (isHtmlUpstreamResult(streamed)) { + sendHtmlUpstreamDiagnostic(res); + return; + } res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'Upstream request failed' })); } else if (!res.writableEnded && !res.destroyed) { @@ -2352,6 +2403,10 @@ function createOpenaiBridgeHttpHandler(options = {}) { if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 400) { if (!shouldFallbackFromUpstreamResponses(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) { + if (isHtmlUpstreamResult(upstreamResponsesResult)) { + sendHtmlUpstreamDiagnostic(res); + return; + } res.writeHead(upstreamResponsesResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' })); return; @@ -2392,6 +2447,10 @@ function createOpenaiBridgeHttpHandler(options = {}) { const upstreamJson = parseJsonOrError(upstreamResult.bodyText); if (upstreamResult.status >= 400) { + if (isHtmlUpstreamResult(upstreamResult)) { + sendHtmlUpstreamDiagnostic(res); + return; + } res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' })); return; diff --git a/tests/e2e/test-task-orchestration.js b/tests/e2e/test-task-orchestration.js index bec7ad8e..8cfb60b5 100644 --- a/tests/e2e/test-task-orchestration.js +++ b/tests/e2e/test-task-orchestration.js @@ -1,4 +1,5 @@ const { spawn } = require('child_process'); +const http = require('http'); const { assert, runSync, fs, path } = require('./helpers'); @@ -118,14 +119,76 @@ process.on('SIGINT', () => server.close(() => process.exit(0))); throw new Error(`OpenAI Chat mock did not start: ${stderr}`); } +async function startFailingOpenAiChatMock(tmpHome, status = 401, message = 'Invalid token') { + const scriptPath = path.join(tmpHome, `task-openai-chat-failing-${status}.cjs`); + const portFile = path.join(tmpHome, `task-openai-chat-failing-${status}.port`); + fs.writeFileSync(scriptPath, ` +const http = require('http'); +const fs = require('fs'); +const portFile = process.argv[2]; +const status = Number(process.argv[3] || 401); +const message = process.argv[4] || 'Invalid token'; +const server = http.createServer((req, res) => { + req.resume(); + req.on('end', () => { + const body = JSON.stringify({ error: { message } }); + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body, 'utf-8') }); + res.end(body, 'utf-8'); + }); +}); +server.listen(0, '127.0.0.1', () => { + fs.writeFileSync(portFile, String(server.address().port), 'utf-8'); +}); +process.on('SIGTERM', () => server.close(() => process.exit(0))); +process.on('SIGINT', () => server.close(() => process.exit(0))); +`, 'utf-8'); + + const child = spawn(process.execPath, [scriptPath, portFile, String(status), message], { + stdio: ['ignore', 'ignore', 'pipe'] + }); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk.toString(); }); + for (let i = 0; i < 80; i += 1) { + if (fs.existsSync(portFile)) { + const port = Number(fs.readFileSync(portFile, 'utf-8').trim()); + if (Number.isFinite(port) && port > 0) { + return { + port, + close() { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode) return resolve(); + const timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch (_) {} + resolve(); + }, 2000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + try { child.kill('SIGTERM'); } catch (_) { resolve(); } + }); + } + }; + } + } + if (child.exitCode !== null) { + throw new Error(`Failing OpenAI Chat mock exited early: ${stderr}`); + } + await sleep(100); + } + try { child.kill('SIGKILL'); } catch (_) {} + throw new Error(`Failing OpenAI Chat mock did not start: ${stderr}`); +} + function writeOpenAiChatConfig(tmpHome, baseUrl) { const configDir = path.join(tmpHome, '.codex'); + const taskConfigDir = path.join(tmpHome, '.codexmate'); fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(taskConfigDir, { recursive: true }); fs.writeFileSync(path.join(configDir, 'codexmate-init.json'), JSON.stringify({ version: 1, mode: 'task-openai-chat-e2e' }), 'utf-8'); fs.writeFileSync(path.join(configDir, 'config.toml'), [ 'model = "gpt-5.3-codex"', 'model_provider = "local"', - 'task_openai_chat_provider = "new-api-chat"', '', '[model_providers.local]', 'name = "Local Codex"', @@ -134,15 +197,121 @@ function writeOpenAiChatConfig(tmpHome, baseUrl) { 'preferred_auth_method = "sk-codex-tab-secret"', 'models = ["gpt-5.3-codex"]', '', - '[model_providers.new-api-chat]', - 'name = "New API Chat"', - `base_url = "${baseUrl}/v1"`, + '[model_providers.codex-coupling-trap]', + 'name = "Codex Coupling Trap"', + 'base_url = "http://127.0.0.1:9/v1"', + 'wire_api = "chat_completions"', + 'preferred_auth_method = "sk-codex-tab-secret"', + 'models = ["codex-trap-model"]', + '' + ].join('\n'), 'utf-8'); + + fs.writeFileSync(path.join(taskConfigDir, 'local-ai-gateway.json'), JSON.stringify({ + selectedProvider: 'local-ai-chat', + selectedModels: { 'local-ai-chat': 'glm-5.2' }, + providers: { + 'local-ai-chat': { + name: 'Local AI Chat', + base_url: `${baseUrl}/v1`, + wire_api: 'chat_completions', + preferred_auth_method: 'sk-local-ai-secret', + temperature: 0.7, + models: ['glm-5.2', 'glm-5.2-flash'], + selected_model: 'glm-5.2' + } + } + }, null, 2), 'utf-8'); + + fs.writeFileSync(path.join(taskConfigDir, 'task-openai-chat-provider.json'), JSON.stringify({ + selectedProvider: 'new-api-chat', + selectedModels: { 'new-api-chat': 'glm-5.2' }, + providers: { + 'new-api-chat': { + name: 'New API Chat', + base_url: `${baseUrl}/v1`, + wire_api: 'chat_completions', + preferred_auth_method: 'sk-task-e2e-secret', + temperature: 0.7, + models: ['glm-5.2', 'glm-5.2-flash'], + selected_model: 'glm-5.2' + } + } + }, null, 2), 'utf-8'); +} + +function writeOpenAiChatFallbackConfig(tmpHome, badBaseUrl, fallbackBaseUrl) { + const configDir = path.join(tmpHome, '.codex'); + const taskConfigDir = path.join(tmpHome, '.codexmate'); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(taskConfigDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'codexmate-init.json'), JSON.stringify({ version: 1, mode: 'task-openai-chat-fallback-e2e' }), 'utf-8'); + fs.writeFileSync(path.join(configDir, 'config.toml'), [ + 'model = "gpt-5.3-codex"', + 'model_provider = "local"', + '', + '[model_providers.local]', + 'name = "Local Codex"', + 'base_url = "http://127.0.0.1:9/v1"', + 'wire_api = "responses"', + 'preferred_auth_method = "sk-codex-tab-secret"', + 'models = ["gpt-5.3-codex"]', + '', + '[model_providers.codex-coupling-trap]', + 'name = "Codex Coupling Trap"', + 'base_url = "http://127.0.0.1:9/v1"', 'wire_api = "chat_completions"', - 'preferred_auth_method = "sk-task-e2e-secret"', - 'temperature = 0.7', - 'models = ["glm-5.2", "glm-5.2-flash"]', + 'preferred_auth_method = "sk-codex-tab-secret"', + 'models = ["codex-trap-model"]', '' ].join('\n'), 'utf-8'); + + fs.writeFileSync(path.join(taskConfigDir, 'local-ai-gateway.json'), JSON.stringify({ + selectedProvider: 'bad-chat', + selectedModels: { 'bad-chat': 'bad-model', 'local-ai-chat': 'glm-5.2' }, + providers: { + 'bad-chat': { + name: 'Bad Chat', + base_url: `${badBaseUrl}/v1`, + wire_api: 'chat_completions', + preferred_auth_method: 'sk-bad-token', + models: ['bad-model'], + selected_model: 'bad-model' + }, + 'local-ai-chat': { + name: 'Local AI Chat', + base_url: `${fallbackBaseUrl}/v1`, + wire_api: 'chat_completions', + preferred_auth_method: 'sk-local-ai-secret', + temperature: 0.7, + models: ['glm-5.2'], + selected_model: 'glm-5.2' + } + } + }, null, 2), 'utf-8'); + + fs.writeFileSync(path.join(taskConfigDir, 'task-openai-chat-provider.json'), JSON.stringify({ + selectedProvider: 'bad-chat', + selectedModels: { 'bad-chat': 'bad-model', 'new-api-chat': 'glm-5.2' }, + providers: { + 'bad-chat': { + name: 'Bad Chat', + base_url: `${badBaseUrl}/v1`, + wire_api: 'chat_completions', + preferred_auth_method: 'sk-bad-token', + models: ['bad-model'], + selected_model: 'bad-model' + }, + 'new-api-chat': { + name: 'New API Chat', + base_url: `${fallbackBaseUrl}/v1`, + wire_api: 'chat_completions', + preferred_auth_method: 'sk-task-e2e-secret', + temperature: 0.7, + models: ['glm-5.2'], + selected_model: 'glm-5.2' + } + } + }, null, 2), 'utf-8'); } function assertOpenAiRunPayload(payload, label) { @@ -159,9 +328,11 @@ function assertOpenAiRequests(mock, minCount, label) { const chatRequests = mock.readRequests().filter(item => item.path === '/v1/chat/completions'); assert(chatRequests.length >= minCount, `${label} should call /v1/chat/completions at least ${minCount} times, got ${chatRequests.length}`); assert(!JSON.stringify(chatRequests).includes('sk-codex-tab-secret'), `${label} must not use the Codex tab provider key`); + assert(chatRequests.some(item => item.authorization === 'Bearer sk-task-e2e-secret'), `${label} should include task provider auth`); + assert(chatRequests.some(item => item.authorization === 'Bearer sk-local-ai-secret'), `${label} should include Local AI provider auth`); for (const item of chatRequests) { assert(item.method === 'POST', `${label} chat request should be POST`); - assert(item.authorization === 'Bearer sk-task-e2e-secret', `${label} should pass bearer auth`); + assert(['Bearer sk-task-e2e-secret', 'Bearer sk-local-ai-secret'].includes(item.authorization), `${label} should pass bearer auth from task or Local AI store`); assert(item.body && item.body.model === 'glm-5.2', `${label} should pass selected model`); assert(item.body.temperature === 0.7, `${label} should pass configured temperature`); assert(Array.isArray(item.body.messages) && item.body.messages.length >= 2, `${label} should send chat messages`); @@ -187,6 +358,35 @@ function parseJsonOutput(rawText) { } } +function requestJson(port, requestPath, method = 'GET', payload = null, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const body = payload === null ? '' : JSON.stringify(payload); + const req = http.request({ + hostname: '127.0.0.1', + port, + path: requestPath, + method, + headers: { + ...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body, 'utf-8') } : {}) + } + }, (res) => { + let raw = ''; + res.setEncoding('utf-8'); + res.on('error', reject); + res.on('data', chunk => { raw += chunk; }); + res.on('end', () => { + let json = null; + try { json = raw ? JSON.parse(raw) : null; } catch (_) {} + resolve({ status: res.statusCode || 0, headers: res.headers || {}, body: raw, json }); + }); + }); + req.on('error', reject); + req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timeout'))); + if (body) req.write(body); + req.end(); + }); +} + module.exports = async function testTaskOrchestration(ctx) { const { api, node, cliPath, env, tmpHome } = ctx; @@ -347,6 +547,77 @@ module.exports = async function testTaskOrchestration(ctx) { try { writeOpenAiChatConfig(tmpHome, `http://codex-user:codex-secret@127.0.0.1:${openAiMock.port}`); + const localAiOverview = await api('local-ai-service-overview'); + assert(localAiOverview && localAiOverview.service === 'local-ai-service', 'Local AI service overview should identify service'); + assert(localAiOverview.storePath === '~/.codexmate/local-ai-gateway.json', 'Local AI service overview should use independent Local AI store'); + assert(localAiOverview.isolation && localAiOverview.isolation.independentConfig === true, 'Local AI overview should mark config as independent'); + assert(localAiOverview.isolation.codexConfigCoupled === false && localAiOverview.isolation.taskConfigCoupled === false, 'Local AI overview should not couple to Codex or Tasks config'); + assert(localAiOverview.gateway && localAiOverview.gateway.chatCompletionsPath === '/bridge/local/v1/chat/completions', 'Local AI overview should expose local OpenAI Chat endpoint'); + assert(localAiOverview.gateway.routingPolicy && localAiOverview.gateway.routingPolicy.failover === true, 'Local AI overview should expose automatic failover policy'); + assert(localAiOverview.gateway.manualSwitching === false, 'Local AI overview should mark manual switching disabled'); + assert(Array.isArray(localAiOverview.providerOptions), 'Local AI service overview should list providers'); + assert(Array.isArray(localAiOverview.routeProviderOptions), 'Local AI service overview should list gateway route providers'); + assert(localAiOverview.providerOptions.some((item) => item.name === 'Local AI Chat' && item.type === 'openai'), 'Local AI service overview should expose Local AI provider'); + assert(localAiOverview.routeProviderOptions.some((item) => item.name === 'Local AI Chat' && item.type === 'openai'), 'Local AI service overview should expose Local AI provider in gateway route stack'); + assert(!localAiOverview.providerOptions.some((item) => item.name === 'New API Chat'), 'Local AI service overview must not read Tasks provider store'); + assert(!JSON.stringify(localAiOverview).includes('sk-task-e2e-secret'), 'Local AI service overview must not leak task provider key'); + assert(!JSON.stringify(localAiOverview).includes('sk-local-ai-secret'), 'Local AI service overview must not leak Local AI provider key'); + assert(!localAiOverview.providerOptions.some((item) => Object.prototype.hasOwnProperty.call(item, 'apiKey')), 'Local AI service overview provider options must not include raw apiKey fields'); + const localAiProviderDetail = await api('get-local-ai-provider', { name: 'local-ai-chat' }); + assert(localAiProviderDetail && localAiProviderDetail.name === 'Local AI Chat', 'get-local-ai-provider should return editable Local AI provider detail'); + assert(localAiProviderDetail.apiKey === 'sk-local-ai-secret', 'get-local-ai-provider should return raw api key for the edit form'); + + const gatewayHealth = await requestJson(ctx.port, '/bridge/local/v1', 'GET'); + assert(gatewayHealth.status === 200, `Local gateway health should succeed: ${gatewayHealth.body}`); + assert(gatewayHealth.json && gatewayHealth.json.object === 'codexmate.local_bridge', 'Local gateway health should identify bridge'); + const gatewayModels = await requestJson(ctx.port, '/bridge/local/v1/models', 'GET'); + assert(gatewayModels.status === 200, `Local gateway models should succeed: ${gatewayModels.body}`); + assert(gatewayModels.json && Array.isArray(gatewayModels.json.data), 'Local gateway models should proxy upstream models'); + const gatewayChat = await requestJson(ctx.port, '/bridge/local/v1/chat/completions', 'POST', { + model: 'glm-5.2', + messages: [ + { role: 'system', content: 'gateway e2e system' }, + { role: 'user', content: 'gateway e2e user' } + ] + }); + assert(gatewayChat.status === 200, `Local gateway chat should succeed: ${gatewayChat.body}`); + assert(gatewayChat.json && Array.isArray(gatewayChat.json.choices), 'Local gateway chat should proxy OpenAI-compatible chat completions'); + assert(String(gatewayChat.headers['x-codexmate-upstream-provider'] || '') === 'local-ai-chat', 'Local gateway chat should report selected Local AI upstream provider'); + assert(!JSON.stringify(gatewayChat.json).includes('sk-task-e2e-secret'), 'Local gateway chat response must not leak task api key'); + assert(!JSON.stringify(gatewayChat.json).includes('sk-local-ai-secret'), 'Local gateway chat response must not leak Local AI api key'); + + const saveClaudeProvider = await api('save-local-ai-provider', { + name: 'claude-local', + type: 'claude', + url: 'https://claude.example.test/v1', + key: 'sk-claude-e2e-secret', + model: 'claude-3-5-sonnet-latest' + }); + assert(saveClaudeProvider && saveClaudeProvider.success === true, 'save-local-ai-provider should save Claude provider'); + const localAiAfterClaude = await api('local-ai-service-overview'); + assert(localAiAfterClaude.providerOptions.some((item) => item.name === 'claude-local' && item.type === 'claude' && item.ready === true), 'Local AI service overview should expose ready Claude provider'); + assert(!JSON.stringify(localAiAfterClaude).includes('sk-claude-e2e-secret'), 'Local AI service overview must not leak Claude provider key'); + const claudeProviderDetail = await api('get-local-ai-provider', { name: 'claude-local' }); + assert(claudeProviderDetail && claudeProviderDetail.apiKey === 'sk-claude-e2e-secret', 'get-local-ai-provider should return Claude provider key for editing'); + + const editClaudeProvider = await api('save-local-ai-provider', { + originalName: 'claude-local', + name: 'claude-local', + type: 'claude', + url: 'https://claude.example.test/v2', + key: '', + model: 'claude-3-7-sonnet-latest' + }); + assert(editClaudeProvider && editClaudeProvider.success === true, 'save-local-ai-provider should edit provider while preserving existing key'); + const localAiAfterClaudeEdit = await api('local-ai-service-overview'); + const editedClaudeProvider = localAiAfterClaudeEdit.providerOptions.find((item) => item.name === 'claude-local'); + assert(editedClaudeProvider && editedClaudeProvider.ready === true, 'edited Local AI provider should stay ready when key is omitted'); + assert(editedClaudeProvider.selectedModel === 'claude-3-7-sonnet-latest', 'edited Local AI provider should update selected model'); + assert(editedClaudeProvider.endpoint === 'https://claude.example.test/v2', 'edited Local AI provider should update endpoint'); + assert(!JSON.stringify(localAiAfterClaudeEdit).includes('sk-claude-e2e-secret'), 'edited Local AI overview must still not leak preserved api key'); + const editedClaudeProviderDetail = await api('get-local-ai-provider', { name: 'claude-local' }); + assert(editedClaudeProviderDetail && editedClaudeProviderDetail.apiKey === 'sk-claude-e2e-secret', 'edited Local AI provider detail should preserve key when edit omits it'); + const openAiPlanResult = runSync(node, [ cliPath, 'task', @@ -668,7 +939,55 @@ module.exports = async function testTaskOrchestration(ctx) { assert(Array.isArray(apiOpenAiOverview.runs), 'OpenAI Chat task-overview API should return runs after execution'); assert(apiOpenAiOverview.runs.some((item) => item.runId === apiOpenAiRun.runId), 'OpenAI Chat task-overview API should include API run'); - assertOpenAiRequests(openAiMock, 6, 'OpenAI Chat full chain'); + const failingOpenAiMock = await startFailingOpenAiChatMock(tmpHome, 401, 'Invalid token'); + try { + writeOpenAiChatFallbackConfig(tmpHome, `http://127.0.0.1:${failingOpenAiMock.port}`, `http://codex-user:codex-secret@127.0.0.1:${openAiMock.port}`); + let gatewayFailoverObserved = false; + for (let i = 0; i < 4; i += 1) { + const gatewayFailoverChat = await requestJson(ctx.port, '/bridge/local/v1/chat/completions', 'POST', { + model: 'glm-5.2', + messages: [ + { role: 'system', content: 'gateway failover system' }, + { role: 'user', content: `gateway failover user ${i}` } + ] + }); + assert(gatewayFailoverChat.status === 200, `Local gateway failover chat should succeed: ${gatewayFailoverChat.body}`); + assert(String(gatewayFailoverChat.headers['x-codexmate-upstream-provider'] || '') === 'local-ai-chat', 'Local gateway failover should land on healthy Local AI provider'); + const attemptedHeader = String(gatewayFailoverChat.headers['x-codexmate-attempted-providers'] || ''); + assert(!attemptedHeader.includes('codex-coupling-trap'), 'Local gateway failover must not read Codex tab providers'); + assert(!attemptedHeader.includes('new-api-chat'), 'Local gateway failover must not read Tasks provider store'); + if (attemptedHeader.includes('bad-chat')) { + gatewayFailoverObserved = true; + break; + } + } + assert(gatewayFailoverObserved, 'Local gateway failover should attempt and bypass the failing provider'); + const fallbackRunResult = runSync(node, [ + cliPath, + 'task', + 'run', + '--target', + 'OpenAI Chat provider fallback 链路', + '--engine', + 'openai-chat', + '--cwd', + path.join(tmpHome, 'task-fallback-workspace'), + '--thread-id', + 'thread-fallback-run', + '--json' + ], { env }); + assert(fallbackRunResult.status === 0, `OpenAI Chat fallback run failed: ${fallbackRunResult.stderr || fallbackRunResult.stdout}`); + const fallbackPayload = parseJsonOutput(fallbackRunResult.stdout); + assertOpenAiRunPayload(fallbackPayload, 'OpenAI Chat fallback run'); + const fallbackOutputs = fallbackPayload.run.nodes.map(item => item && item.output ? item.output : {}); + assert(fallbackOutputs.some(item => Array.isArray(item.attemptedProviders) && item.attemptedProviders.includes('bad-chat') && item.attemptedProviders.includes('new-api-chat')), 'OpenAI Chat fallback should record attempted providers'); + const fallbackLogs = JSON.stringify(fallbackPayload.run.nodes.flatMap(item => Array.isArray(item && item.logs) ? item.logs : [])); + assert(fallbackLogs.includes('provider fallback: bad-chat -> new-api-chat'), 'OpenAI Chat fallback should be visible in logs'); + } finally { + await failingOpenAiMock.close(); + } + + assertOpenAiRequests(openAiMock, 7, 'OpenAI Chat full chain'); } finally { await openAiMock.close(); } diff --git a/tests/unit/builtin-proxy-responses-shim.test.mjs b/tests/unit/builtin-proxy-responses-shim.test.mjs index e7d15bbc..b5376952 100644 --- a/tests/unit/builtin-proxy-responses-shim.test.mjs +++ b/tests/unit/builtin-proxy-responses-shim.test.mjs @@ -128,6 +128,29 @@ function createNestedNamespace(depth, leafTool) { return current; } +test('builtin-proxy refuses loopback HTTPS default-port upstreams before dialing 443', async () => { + let proxyRuntime; + try { + proxyRuntime = await startTestProxy(1, { baseUrl: 'https://127.0.0.1/v1' }); + const proxyPort = proxyRuntime.server.address().port; + + const resp = await requestText(`http://127.0.0.1:${proxyPort}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { model: 'deepseek-v4-pro', messages: [{ role: 'user', content: 'ping' }] } + }); + + assert.equal(resp.status, 502); + assert.match(resp.text, /Refusing loopback HTTPS default-port upstream/); + assert.match(resp.text, /http:\/\/127\.0\.0\.1:\/v1/); + assert.doesNotMatch(resp.text, /ECONNREFUSED/); + } finally { + if (proxyRuntime) { + await closeServer(proxyRuntime.server, proxyRuntime.connections); + } + } +}); + test('builtin-proxy /v1/responses sends Codex client identity upstream', async () => { let capturedHeaders = null; const upstream = http.createServer((req, res) => { diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 96152d4f..8d91f7c6 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -14,10 +14,14 @@ test('config template keeps expected config tabs in top and side navigation', () const sessionsPanel = readProjectFile('web-ui/partials/index/panel-sessions.html'); const usagePanel = readProjectFile('web-ui/partials/index/panel-usage.html'); const orchestrationPanel = readProjectFile('web-ui/partials/index/panel-orchestration.html'); + const localAiPanel = readProjectFile('web-ui/partials/index/panel-local-ai.html'); + const localRequestsPanel = readProjectFile('web-ui/partials/index/panel-local-requests.html'); const bundledScript = readBundledWebUiScript(); + const precompiledRender = readProjectFile('web-ui/res/web-ui-render.precompiled.js'); const baseTheme = readProjectFile('web-ui/styles/base-theme.css'); const controlsForms = readProjectFile('web-ui/styles/controls-forms.css'); const taskOrchestrationStyles = readProjectFile('web-ui/styles/task-orchestration.css'); + const localAiStyles = readProjectFile('web-ui/styles/local-ai-service.css'); const layoutShell = readProjectFile('web-ui/styles/layout-shell.css'); const bundledStyles = readBundledWebUiCss(); const sideRail = html.match(/