From ab398be558fe7470fde82aff92898abfb60436e1 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sat, 4 Jul 2026 03:39:48 +0000 Subject: [PATCH 01/27] feat(local-ai): isolate local gateway console --- cli.js | 643 +++- cli/local-bridge.js | 131 +- tests/e2e/test-task-orchestration.js | 312 +- tests/unit/config-tabs-ui.test.mjs | 332 +- tests/unit/i18n-locales.test.mjs | 29 +- .../session-tab-switch-performance.test.mjs | 46 + tests/unit/task-codex-exec-windows.test.mjs | 174 +- tests/unit/web-ui-behavior-parity.test.mjs | 24 +- tests/unit/web-ui-logic.test.mjs | 578 ++- ...-ui-runtime-navigation-regression.test.mjs | 6 +- web-ui/app.js | 15 +- web-ui/index.html | 1 + web-ui/modules/app.computed.main-tabs.mjs | 122 +- web-ui/modules/app.methods.navigation.mjs | 7 +- .../app.methods.task-orchestration.mjs | 475 ++- web-ui/modules/i18n/locales/en.mjs | 232 +- web-ui/modules/i18n/locales/ja.mjs | 240 +- web-ui/modules/i18n/locales/vi.mjs | 232 +- web-ui/modules/i18n/locales/zh-tw.mjs | 234 +- web-ui/modules/i18n/locales/zh.mjs | 234 +- web-ui/modules/task-kilo-webview-host.mjs | 386 ++ web-ui/partials/index/layout-header.html | 48 +- web-ui/partials/index/panel-dashboard.html | 2 +- web-ui/partials/index/panel-local-ai.html | 152 + .../partials/index/panel-orchestration.html | 658 +--- web-ui/res/web-ui-render.precompiled.js | 1503 +++----- web-ui/session-helpers.mjs | 8 +- web-ui/styles.css | 1 + web-ui/styles/layout-shell.css | 13 + web-ui/styles/local-ai-service.css | 345 ++ web-ui/styles/navigation-panels.css | 16 + web-ui/styles/task-orchestration.css | 3268 +++-------------- 32 files changed, 5184 insertions(+), 5283 deletions(-) create mode 100644 web-ui/modules/task-kilo-webview-host.mjs create mode 100644 web-ui/partials/index/panel-local-ai.html create mode 100644 web-ui/styles/local-ai-service.css diff --git a/cli.js b/cli.js index 742b49aa..9635bab4 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,7 +387,7 @@ 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, @@ -12521,6 +12523,24 @@ 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 '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 : {}; @@ -16074,6 +16094,8 @@ function buildTaskOverviewPayload(options = {}) { return { workflows: workflowCatalog.workflows, openAiChatStatus: buildTaskOpenAiChatStatus(), + localAiServiceStatus: buildLocalAiServiceStatus(), + webviewBridgeConfig: readTaskOpenAiChatProviderStore().webviewBridge, warnings, queue, runs, @@ -16168,6 +16190,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 +16206,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 +16223,402 @@ 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 + }; + 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; + 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] || models[0] || '', + enabled: provider.enabled !== false, + temperature: Number.isFinite(Number(provider.temperature)) ? Number(provider.temperature) : undefined + }; + if (modelProviders[key].temperature === undefined) delete modelProviders[key].temperature; + }); + return { model_providers: modelProviders }; +} + +function buildLocalAiProviderOption(key, provider, store) { + const models = listTaskProviderModels(provider); + const type = provider.type === 'claude' ? 'claude' : 'openai'; + const hasApiKey = typeof provider.preferred_auth_method === 'string' && provider.preferred_auth_method.trim().length > 0; + const endpoint = provider.endpoint || provider.base_url || ''; + const selectedModel = provider.selected_model || store.selectedModels?.[key] || models[0] || ''; + 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 && hasApiKey && models.length > 0 && !!endpoint, + hasApiKey, + hasExtraHeaders: false, + routeRole: type === 'openai' ? 'gateway-upstream' : 'managed-provider' + }; +} + +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 activeRouteProvider = routeProviderOptions.find((item) => item.ready) || routeProviderOptions[0] || null; + const gatewayReady = routeProviderOptions.some((item) => item.ready); + 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, + configError: '', + isolation: { + independentConfig: true, + codexConfigCoupled: false, + taskConfigCoupled: false, + source: 'local-ai-gateway' + }, + gateway: { + basePath: '/bridge/local/v1', + 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 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 = readLocalAiGatewayStore(); + 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); + const temperature = Number.isFinite(Number(params.temperature)) ? Number(params.temperature) : undefined; + 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, + temperature + }; + if (store.providers[name].temperature === undefined) delete store.providers[name].temperature; + store.selectedProvider = name; + store.selectedModels = { ...(store.selectedModels || {}), [name]: model }; + 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 +16628,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 +16638,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 +16750,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 +16760,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 +16770,8 @@ function buildTaskOpenAiChatStatus() { hasApiKey, hasExtraHeaders }; + if (providerOptions.length) status.providerOptions = providerOptions; + return status; } function postOpenAiChatCompletion(requestConfig, body, options = {}) { @@ -16529,37 +17011,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 +17081,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 +17163,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/local-bridge.js b/cli/local-bridge.js index 8d7ce140..f6218367 100644 --- a/cli/local-bridge.js +++ b/cli/local-bridge.js @@ -103,7 +103,10 @@ function buildUpstreamPool(readConfigFn, openaiBridgeFile, excludedProviders) { const baseUrl = typeof p.base_url === 'string' ? p.base_url.trim() : ''; if (!isValidHttpUrl(normalizeBaseUrl(baseUrl))) 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; + pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), authMethod, requiresOpenaiAuth: !!p.requires_openai_auth, model, temperature }); } if (pool.length === 0) return { error: '请先添加上游 provider' }; return { pool }; @@ -151,8 +154,11 @@ 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 createLocalBridgeHttpHandler(options = {}) { @@ -186,6 +192,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); } @@ -722,25 +742,98 @@ 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 (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 shouldFailover = (result) => { + if (!result || !result.ok) 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); + const attemptBase = attemptEntry.baseUrl.replace(/\/+$/, ''); + const upstreamUrl = joinApiUrl(attemptBase, normalizedSuffix); + let attemptBody = passthroughBody; + if (normalizedSuffix === 'chat/completions' && attemptBody && typeof attemptBody === 'object' && !Array.isArray(attemptBody)) { + attemptBody = { ...attemptBody }; + if (!attemptBody.model && 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 retryTransientRequest(() => proxyRequestJson(upstreamUrl, { + method, + body: attemptBody, + headers: { + ...(attemptAuthHeader ? { Authorization: attemptAuthHeader } : {}), + ...(attemptBody !== null ? { 'Content-Type': 'application/json' } : {}) + }, + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + })); + lastResult = upstreamResult; + lastEntry = attemptEntry; + if (!shouldFailover(upstreamResult)) { + recordSuccess(attemptEntry.name); + 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; + } + recordFailure(attemptEntry.name); + } + + if (!lastResult || !lastResult.ok) { + res.writeHead(502, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Codexmate-Attempted-Providers': attemptedProviders.join(',') + }); + res.end(JSON.stringify({ error: `Upstream request failed: ${lastResult && lastResult.error ? lastResult.error : 'unknown error'}` })); return; } - recordSuccess(entry.name); - res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(upstreamResult.bodyText); + 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/tests/e2e/test-task-orchestration.js b/tests/e2e/test-task-orchestration.js index bec7ad8e..8e095eff 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,34 @@ 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('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 +546,53 @@ 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'); + + 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 openAiPlanResult = runSync(node, [ cliPath, 'task', @@ -668,7 +914,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/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 96152d4f..0c6d2bb3 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -14,10 +14,13 @@ 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 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(/