From 83a28c07253fa66f0b575600023d86590e93348b Mon Sep 17 00:00:00 2001 From: SurviveM <254925152+SurviveM@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:30:13 +0800 Subject: [PATCH 1/4] fix: refine codex health check dialog flow --- cli.js | 597 ++++++------------ cli/config-health.js | 338 ++++++++++ lib/cli-models-utils.js | 213 ++++++- lib/cli-network-utils.js | 218 ++++--- package.json | 1 + tests/e2e/helpers.js | 33 +- tests/e2e/recent-health.e2e.js | 25 +- tests/e2e/run.js | 15 +- tests/e2e/test-health-speed.js | 80 ++- tests/unit/agents-modal-guards.test.mjs | 127 ++++ tests/unit/cli-network-utils.test.mjs | 63 ++ tests/unit/config-health-module.test.mjs | 155 +++++ tests/unit/config-tabs-ui.test.mjs | 15 +- tests/unit/provider-chat-utils.test.mjs | 43 ++ tests/unit/run.mjs | 2 + tests/unit/web-ui-behavior-parity.test.mjs | 13 +- web-ui/app.js | 7 + web-ui/index.html | 1 + web-ui/modules/app.methods.codex-config.mjs | 133 +++- web-ui/partials/index/modal-health-check.html | 72 +++ web-ui/partials/index/panel-config-codex.html | 64 +- web-ui/styles.css | 1 + web-ui/styles/health-check-dialog.css | 144 +++++ 23 files changed, 1777 insertions(+), 583 deletions(-) create mode 100644 cli/config-health.js create mode 100644 tests/unit/cli-network-utils.test.mjs create mode 100644 tests/unit/config-health-module.test.mjs create mode 100644 tests/unit/provider-chat-utils.test.mjs create mode 100644 web-ui/partials/index/modal-health-check.html create mode 100644 web-ui/styles/health-check-dialog.css diff --git a/cli.js b/cli.js index f79435d..039e7a1 100644 --- a/cli.js +++ b/cli.js @@ -42,12 +42,15 @@ const { buildLineDiff } = require('./lib/text-diff'); const { extractModelNames, hasModelsListPayload, - extractModelIds, - buildModelsProbeUrl, + buildModelsCacheKey, buildModelProbeSpec, - buildModelsCacheKey + buildModelConversationSpecs, + extractModelResponseText } = require('./lib/cli-models-utils'); -const { probeUrl, probeJsonPost } = require('./lib/cli-network-utils'); +const { + probeUrl, + probeJsonPost +} = require('./lib/cli-network-utils'); const { toIsoTime, updateLatestIso, @@ -62,6 +65,7 @@ const { validateWorkflowDefinition, executeWorkflowDefinition } = require('./lib/workflow-engine'); +const { buildConfigHealthReport: buildConfigHealthReportCore } = require('./cli/config-health'); const { readBundledWebUiCss, readBundledWebUiHtml, @@ -105,7 +109,6 @@ const CODEX_BACKUP_NAME = 'codex-config'; const DEFAULT_MODELS = ['gpt-5.3-codex', 'gpt-5.1-codex-max', 'gpt-4-turbo', 'gpt-4']; const SPEED_TEST_TIMEOUT_MS = 8000; -const HEALTH_CHECK_TIMEOUT_MS = 6000; const MAX_SESSION_LIST_SIZE = 300; const MAX_SESSION_TRASH_LIST_SIZE = 500; const MAX_EXPORT_MESSAGES = 1000; @@ -2817,358 +2820,11 @@ function recordRecentConfig(provider, model) { writeRecentConfigs(trimmed); } -async function runRemoteHealthCheck(provider, modelName, options = {}) { - const issues = []; - const results = {}; - const baseUrl = normalizeBaseUrl(provider && provider.base_url ? provider.base_url : ''); - if (!baseUrl) { - issues.push({ - code: 'remote-skip-base-url', - message: '无法进行远程探测:base_url 为空', - suggestion: '补全 base_url 或关闭远程探测' - }); - return { issues, results }; - } - - const requiresAuth = provider && provider.requires_openai_auth !== false; - const apiKey = typeof provider.preferred_auth_method === 'string' - ? provider.preferred_auth_method.trim() - : ''; - const authValue = requiresAuth ? apiKey : (apiKey || ''); - const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : HEALTH_CHECK_TIMEOUT_MS; - - const baseProbe = await probeUrl(baseUrl, { apiKey: authValue, timeoutMs }); - results.base = { - url: baseUrl, - status: baseProbe.status || 0, - ok: baseProbe.ok, - durationMs: baseProbe.durationMs || 0 - }; - - if (!baseProbe.ok) { - issues.push({ - code: 'remote-unreachable', - message: `远程探测失败:${baseProbe.error || '无法连接'}`, - suggestion: '检查网络与 base_url 可达性' - }); - return { issues, results }; - } - - if (baseProbe.status === 401 || baseProbe.status === 403) { - issues.push({ - code: 'remote-auth-failed', - message: '远程探测鉴权失败(401/403)', - suggestion: '检查 API Key 或认证方式' - }); - } else if (baseProbe.status >= 400) { - issues.push({ - code: 'remote-http-error', - message: `远程探测返回异常状态: ${baseProbe.status}`, - suggestion: '检查 base_url 是否正确' - }); - } - - const modelsUrl = buildModelsProbeUrl(baseUrl); - if (modelsUrl) { - const modelsProbe = await probeUrl(modelsUrl, { apiKey: authValue, timeoutMs, maxBytes: 256 * 1024 }); - results.models = { - url: modelsUrl, - status: modelsProbe.status || 0, - ok: modelsProbe.ok, - durationMs: modelsProbe.durationMs || 0 - }; - - if (!modelsProbe.ok) { - issues.push({ - code: 'remote-models-unreachable', - message: `模型列表探测失败:${modelsProbe.error || '无法连接'}`, - suggestion: '检查 base_url 是否包含 /v1 或关闭远程探测' - }); - } else if (modelsProbe.status === 401 || modelsProbe.status === 403) { - issues.push({ - code: 'remote-models-auth-failed', - message: '模型列表鉴权失败(401/403)', - suggestion: '检查 API Key 或认证方式' - }); - } else if (modelsProbe.status >= 400) { - issues.push({ - code: 'remote-models-http-error', - message: `模型列表返回异常状态: ${modelsProbe.status}`, - suggestion: '确认 /v1/models 可用' - }); - } else { - let payload = null; - try { - payload = modelsProbe.body ? JSON.parse(modelsProbe.body) : null; - } catch (e) { - issues.push({ - code: 'remote-models-parse', - message: '模型列表解析失败(非 JSON)', - suggestion: '确认 /v1/models 返回 JSON' - }); - } - - if (payload) { - const ids = extractModelIds(payload); - if (ids.length === 0) { - issues.push({ - code: 'remote-models-empty', - message: '模型列表为空或结构无法识别', - suggestion: '确认 provider 是否兼容 /v1/models' - }); - } else if (modelName && !ids.includes(modelName)) { - issues.push({ - code: 'remote-model-unavailable', - message: `远程模型列表中未找到: ${modelName}`, - suggestion: '切换模型或确认模型名称' - }); - } - } - } - } - - const modelProbeSpec = buildModelProbeSpec(provider, modelName, baseUrl); - if (modelProbeSpec && modelProbeSpec.url) { - const modelProbe = await probeJsonPost(modelProbeSpec.url, modelProbeSpec.body, { - apiKey: authValue, - timeoutMs, - maxBytes: 256 * 1024 - }); - - results.modelProbe = { - url: modelProbeSpec.url, - status: modelProbe.status || 0, - ok: modelProbe.ok, - durationMs: modelProbe.durationMs || 0 - }; - - if (!modelProbe.ok) { - issues.push({ - code: 'remote-model-probe-unreachable', - message: `模型可用性探测失败:${modelProbe.error || '无法连接'}`, - suggestion: '检查网络或模型接口是否可用' - }); - } else if (modelProbe.status === 401 || modelProbe.status === 403) { - issues.push({ - code: 'remote-model-probe-auth-failed', - message: '模型可用性探测鉴权失败(401/403)', - suggestion: '检查 API Key 或认证方式' - }); - } else if (modelProbe.status >= 400) { - issues.push({ - code: 'remote-model-probe-http-error', - message: `模型可用性探测返回异常状态: ${modelProbe.status}`, - suggestion: '检查模型或接口路径' - }); - } else { - let payload = null; - try { - payload = modelProbe.body ? JSON.parse(modelProbe.body) : null; - } catch (e) { - issues.push({ - code: 'remote-model-probe-parse', - message: '模型可用性探测解析失败(非 JSON)', - suggestion: '确认模型接口返回 JSON' - }); - } - if (payload && payload.error) { - const message = typeof payload.error.message === 'string' - ? payload.error.message - : '模型接口返回错误'; - issues.push({ - code: 'remote-model-probe-error', - message: `模型可用性探测失败:${message}`, - suggestion: '检查模型名与权限' - }); - } - } - } - - return { issues, results }; -} - async function buildConfigHealthReport(params = {}) { - const issues = []; - const status = readConfigOrVirtualDefault(); - const config = status.config || {}; - - if (status.isVirtual) { - const parseFailed = status.errorType === 'parse'; - const readFailed = status.errorType === 'read'; - issues.push({ - code: parseFailed ? 'config-parse-failed' : (readFailed ? 'config-read-failed' : 'config-missing'), - message: status.reason || (parseFailed - ? 'config.toml 解析失败' - : (readFailed ? '读取 config.toml 失败' : '未检测到 config.toml')), - suggestion: parseFailed - ? '修复 config.toml 语法错误后重试' - : (readFailed ? '检查文件权限后重试' : '在模板编辑器中确认应用配置,生成可用的 config.toml') - }); - if (parseFailed || readFailed) { - return { - ok: false, - issues, - summary: { - currentProvider: '', - currentModel: '' - }, - remote: null - }; - } - } - - const providerName = typeof config.model_provider === 'string' ? config.model_provider.trim() : ''; - const modelName = typeof config.model === 'string' ? config.model.trim() : ''; - if (!providerName) { - issues.push({ - code: 'provider-missing', - message: '当前 provider 未设置', - suggestion: '在模板中设置 model_provider' - }); - } - - if (!modelName) { - issues.push({ - code: 'model-missing', - message: '当前模型未设置', - suggestion: '在模板中设置 model' - }); - } - - const providers = config.model_providers && typeof config.model_providers === 'object' - ? config.model_providers - : {}; - const provider = providerName ? providers[providerName] : null; - if (providerName && !provider) { - issues.push({ - code: 'provider-not-found', - message: `当前 provider 未在配置中找到: ${providerName}`, - suggestion: '检查 model_providers 是否包含该 provider 配置块' - }); - } - - if (provider && typeof provider === 'object') { - const baseUrl = typeof provider.base_url === 'string' ? provider.base_url.trim() : ''; - if (!isValidHttpUrl(baseUrl)) { - issues.push({ - code: 'base-url-invalid', - message: '当前 provider 的 base_url 无效', - suggestion: '请设置为 http/https 的完整 URL' - }); - } - - const requiresAuth = provider.requires_openai_auth; - if (requiresAuth !== false) { - const apiKey = typeof provider.preferred_auth_method === 'string' - ? provider.preferred_auth_method.trim() - : ''; - if (!apiKey) { - issues.push({ - code: 'api-key-missing', - message: '当前 provider 未配置 API Key', - suggestion: '在模板中设置 preferred_auth_method' - }); - } - } - } - - if (modelName) { - const models = readModels(); - if (!models.includes(modelName)) { - issues.push({ - code: 'model-unavailable', - message: `模型未在可用列表中找到: ${modelName}`, - suggestion: '在模型列表中添加该模型或切换到已有模型' - }); - } - } - - const remoteEnabled = !!params.remote; - let remote = null; - if (remoteEnabled) { - const baseUrl = provider && typeof provider.base_url === 'string' ? provider.base_url.trim() : ''; - if (!provider) { - issues.push({ - code: 'remote-skip-provider', - message: '无法进行远程探测:provider 未找到', - suggestion: '检查 model_provider 配置或关闭远程探测' - }); - } else if (!isValidHttpUrl(baseUrl)) { - issues.push({ - code: 'remote-skip-base-url', - message: '无法进行远程探测:base_url 无效', - suggestion: '补全 base_url 或关闭远程探测' - }); - } else { - const timeoutMs = Number.isFinite(params.timeoutMs) - ? Math.max(1000, Number(params.timeoutMs)) - : undefined; - const apiKey = typeof provider.preferred_auth_method === 'string' - ? provider.preferred_auth_method.trim() - : ''; - const speedResult = await runSpeedTest(baseUrl, apiKey, { timeoutMs }); - const status = speedResult && typeof speedResult.status === 'number' - ? speedResult.status - : 0; - const durationMs = speedResult && typeof speedResult.durationMs === 'number' - ? speedResult.durationMs - : 0; - const error = speedResult && speedResult.error ? String(speedResult.error) : ''; - remote = { - type: 'speed-test', - url: baseUrl, - ok: !!speedResult.ok, - status, - durationMs, - error - }; - - if (!speedResult.ok) { - const errorLower = error.toLowerCase(); - if (errorLower.includes('timeout')) { - issues.push({ - code: 'remote-speedtest-timeout', - message: '远程测速超时', - suggestion: '检查网络或 base_url 是否可达' - }); - } else if (errorLower.includes('invalid url')) { - issues.push({ - code: 'remote-speedtest-invalid-url', - message: '远程测速失败:base_url 无效', - suggestion: '请设置为 http/https 的完整 URL' - }); - } else { - issues.push({ - code: 'remote-speedtest-unreachable', - message: `远程测速失败:${error || '无法连接'}`, - suggestion: '检查网络或 base_url 是否可用' - }); - } - } else if (status === 401 || status === 403) { - issues.push({ - code: 'remote-speedtest-auth-failed', - message: '远程测速鉴权失败(401/403)', - suggestion: '检查 API Key 或认证方式' - }); - } else if (status >= 400) { - issues.push({ - code: 'remote-speedtest-http-error', - message: `远程测速返回异常状态: ${status}`, - suggestion: '检查 base_url 或服务状态' - }); - } - } - } - - return { - ok: issues.length === 0, - issues, - summary: { - currentProvider: providerName, - currentModel: modelName - }, - remote - }; + return buildConfigHealthReportCore(params, { + readConfigOrVirtualDefault, + readModels + }); } function buildDefaultConfigContent(initializedAt) { @@ -7678,63 +7334,210 @@ function resolveSpeedTestTarget(params) { if (!provider.base_url) { return { error: 'Provider missing URL' }; } + const currentModel = typeof config.model === 'string' ? config.model.trim() : ''; + const probeSpec = buildModelProbeSpec(provider, currentModel, provider.base_url); + if (probeSpec && probeSpec.url) { + return { + method: 'POST', + url: probeSpec.url, + body: probeSpec.body, + apiKey: provider.preferred_auth_method || '' + }; + } return { + method: 'GET', url: provider.base_url, apiKey: provider.preferred_auth_method || '' }; } if (params.url) { - return { url: params.url, apiKey: '' }; + return { + method: 'GET', + url: params.url, + apiKey: typeof params.apiKey === 'string' ? params.apiKey : '' + }; } return { error: 'Missing name or url' }; } -function runSpeedTest(targetUrl, apiKey, options = {}) { - return new Promise((resolve) => { - let parsed; - try { - parsed = new URL(targetUrl); - } catch (e) { - return resolve({ ok: false, error: 'Invalid URL' }); - } +function extractApiPayloadErrorMessage(payload) { + if (!payload || typeof payload !== 'object') { + return ''; + } + if (typeof payload.error === 'string' && payload.error.trim()) { + return payload.error.trim(); + } + if (!payload.error || typeof payload.error !== 'object') { + return ''; + } + if (typeof payload.error.message === 'string' && payload.error.message.trim()) { + return payload.error.message.trim(); + } + if (typeof payload.error.code === 'string' && payload.error.code.trim()) { + return payload.error.code.trim(); + } + return ''; +} - const timeoutMs = Number.isFinite(options.timeoutMs) - ? Math.max(1000, Number(options.timeoutMs)) - : SPEED_TEST_TIMEOUT_MS; +function resolveProviderChatTarget(params) { + const providerName = typeof (params && params.name) === 'string' ? params.name.trim() : ''; + const prompt = typeof (params && params.prompt) === 'string' ? params.prompt.trim() : ''; + if (!providerName) { + return { error: 'Provider name is required' }; + } + if (!prompt) { + return { error: 'Prompt is required' }; + } - const transport = parsed.protocol === 'https:' ? https : http; - const headers = { - 'User-Agent': 'codexmate-speed-test', - 'Accept': 'application/json' - }; - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}`; - } + const { config } = readConfigOrVirtualDefault(); + const providers = config.model_providers || {}; + const provider = providers[providerName]; + if (!provider || typeof provider !== 'object') { + return { error: `Provider not found: ${providerName}` }; + } - const start = Date.now(); - const req = transport.request(parsed, { method: 'GET', headers }, (res) => { - res.on('data', () => {}); - res.on('end', () => { - resolve({ - ok: true, - status: res.statusCode || 0, - durationMs: Date.now() - start - }); - }); - }); + const baseUrl = typeof provider.base_url === 'string' ? provider.base_url.trim() : ''; + if (!baseUrl) { + return { error: `Provider ${providerName} missing URL` }; + } - req.setTimeout(timeoutMs, () => { - req.destroy(new Error('timeout')); - }); + const currentModels = readCurrentModels(); + const savedModel = currentModels && typeof currentModels[providerName] === 'string' + ? currentModels[providerName].trim() + : ''; + const activeProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : ''; + const activeModel = typeof config.model === 'string' ? config.model.trim() : ''; + const model = savedModel || (activeProvider === providerName ? activeModel : ''); + if (!model) { + return { error: `Provider ${providerName} missing current model` }; + } + + const specs = buildModelConversationSpecs(provider, model, baseUrl, prompt, { + maxOutputTokens: 256 + }); + if (!specs.length) { + return { error: `Provider ${providerName} missing available conversation endpoint` }; + } + + return { + providerName, + provider, + model, + prompt, + specs, + apiKey: typeof provider.preferred_auth_method === 'string' + ? provider.preferred_auth_method.trim() + : '' + }; +} + +async function runProviderChatCheck(params = {}) { + const target = resolveProviderChatTarget(params); + if (target.error) { + return { ok: false, error: target.error }; + } - req.on('error', (err) => { - resolve({ ok: false, error: err.message, durationMs: Date.now() - start }); + const timeoutMs = Number.isFinite(params.timeoutMs) + ? Math.max(1000, Number(params.timeoutMs)) + : 30000; + let finalSpec = target.specs[0]; + let result = null; + + for (let index = 0; index < target.specs.length; index += 1) { + const candidate = target.specs[index]; + const probeResult = await probeJsonPost(candidate.url, candidate.body, { + apiKey: target.apiKey, + timeoutMs, + maxBytes: 512 * 1024 }); + finalSpec = candidate; + result = probeResult; + const shouldTryNextCandidate = index < target.specs.length - 1 + && (!probeResult.ok || probeResult.status === 404); + if (!shouldTryNextCandidate) { + break; + } + } - req.end(); - }); + if (!result || !result.ok) { + return { + ok: false, + provider: target.providerName, + model: target.model, + url: finalSpec.url, + status: Number.isFinite(result && result.status) ? result.status : 0, + durationMs: Number.isFinite(result && result.durationMs) ? result.durationMs : 0, + reply: '', + rawPreview: '', + error: result && result.error ? result.error : 'request failed' + }; + } + + let payload = null; + try { + payload = result.body ? JSON.parse(result.body) : null; + } catch (e) { + payload = null; + } + + const payloadError = extractApiPayloadErrorMessage(payload); + if (result.status >= 400 || payloadError) { + return { + ok: false, + provider: target.providerName, + model: target.model, + url: finalSpec.url, + status: Number.isFinite(result.status) ? result.status : 0, + durationMs: Number.isFinite(result.durationMs) ? result.durationMs : 0, + reply: '', + rawPreview: result.body ? truncateText(result.body, 600) : '', + error: payloadError || `HTTP ${result.status}` + }; + } + + const reply = extractModelResponseText(payload); + return { + ok: true, + provider: target.providerName, + model: target.model, + url: finalSpec.url, + status: Number.isFinite(result.status) ? result.status : 0, + durationMs: Number.isFinite(result.durationMs) ? result.durationMs : 0, + reply, + rawPreview: reply ? '' : (result.body ? truncateText(result.body, 600) : ''), + error: '' + }; +} + +function runSpeedTest(targetUrl, apiKey, options = {}) { + const timeoutMs = Number.isFinite(options.timeoutMs) + ? Math.max(1000, Number(options.timeoutMs)) + : SPEED_TEST_TIMEOUT_MS; + const method = typeof options.method === 'string' ? options.method.toUpperCase() : 'GET'; + if (method === 'POST') { + return probeJsonPost(targetUrl, options.body || {}, { + apiKey, + timeoutMs, + maxBytes: 256 * 1024 + }).then((result) => ({ + ok: !!result.ok, + status: Number.isFinite(result.status) ? result.status : 0, + durationMs: Number.isFinite(result.durationMs) ? result.durationMs : 0, + error: result.ok ? '' : (result.error || '') + })); + } + return probeUrl(targetUrl, { + apiKey, + timeoutMs, + maxBytes: 256 * 1024 + }).then((result) => ({ + ok: !!result.ok, + status: Number.isFinite(result.status) ? result.status : 0, + durationMs: Number.isFinite(result.durationMs) ? result.durationMs : 0, + error: result.ok ? '' : (result.error || '') + })); } // ============================================================================ @@ -10592,7 +10395,11 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser result = { error: target.error }; break; } - result = await runSpeedTest(target.url, target.apiKey); + result = await runSpeedTest(target.url, target.apiKey, target); + break; + } + case 'provider-chat-check': { + result = await runProviderChatCheck(params || {}); break; } case 'list-sessions': diff --git a/cli/config-health.js b/cli/config-health.js new file mode 100644 index 0000000..55ed544 --- /dev/null +++ b/cli/config-health.js @@ -0,0 +1,338 @@ +const { isValidHttpUrl, normalizeBaseUrl } = require('../lib/cli-utils'); +const { buildModelProbeSpecs } = require('../lib/cli-models-utils'); +const { probeJsonPost } = require('../lib/cli-network-utils'); + +const DEFAULT_TIMEOUT_MS = 6000; +const JSON_RESPONSE_MAX_BYTES = 256 * 1024; + +function buildRemoteHealthMessage(issueCode, statusCode, detail) { + if (!issueCode) { + return '远程模型探测通过:endpoint、鉴权与模型均可用'; + } + + if (issueCode === 'remote-model-probe-unreachable') { + return '远程模型接口不可达,请检查 endpoint、网络或 DNS'; + } + + if (issueCode === 'remote-model-probe-auth-failed') { + return '远程模型探测鉴权失败(401/403),请检查 API Key、endpoint 与模型权限'; + } + + if (issueCode === 'remote-model-probe-not-found') { + return '远程模型探测返回 404,请检查 base_url、接口路径或模型名'; + } + + if (issueCode === 'remote-model-probe-error') { + return detail || '远程模型接口返回错误,请检查模型名与账号权限'; + } + + if (issueCode === 'remote-model-probe-http-error' && statusCode) { + return `远程模型探测返回 HTTP ${statusCode},请检查 endpoint 与模型`; + } + + return '远程模型探测失败,请检查配置与远端服务状态'; +} + +function extractPayloadErrorMessage(payload) { + if (!payload || typeof payload !== 'object') { + return ''; + } + if (typeof payload.error === 'string' && payload.error.trim()) { + return payload.error.trim(); + } + if (!payload.error || typeof payload.error !== 'object') { + return ''; + } + if (typeof payload.error.message === 'string' && payload.error.message.trim()) { + return payload.error.message.trim(); + } + if (typeof payload.error.code === 'string' && payload.error.code.trim()) { + return payload.error.code.trim(); + } + return ''; +} + +async function runRemoteHealthCheck(providerName, provider, modelName, options = {}) { + const issues = []; + const baseUrl = normalizeBaseUrl(provider && provider.base_url ? provider.base_url : ''); + const summary = { + type: 'remote-health-check', + provider: typeof providerName === 'string' ? providerName.trim() : '', + endpoint: baseUrl, + ok: false, + statusCode: null, + message: '', + checks: {} + }; + + if (!baseUrl) { + issues.push({ + code: 'remote-skip-base-url', + message: '无法进行远程探测:base_url 为空', + suggestion: '补全 base_url 或关闭远程探测' + }); + summary.message = '无法进行远程探测:base_url 为空'; + return { issues, remote: summary }; + } + + if (!isValidHttpUrl(baseUrl)) { + issues.push({ + code: 'remote-skip-base-url', + message: '无法进行远程探测:base_url 无效', + suggestion: '补全 base_url 或关闭远程探测' + }); + summary.message = '无法进行远程探测:base_url 无效'; + return { issues, remote: summary }; + } + + const modelProbeSpecs = buildModelProbeSpecs(provider, modelName, baseUrl); + if (!modelProbeSpecs.length) { + issues.push({ + code: 'remote-skip-model', + message: '无法进行远程探测:当前模型未设置', + suggestion: '补全 model 后重试' + }); + summary.message = '无法进行远程探测:当前模型未设置'; + return { issues, remote: summary }; + } + + const requiresAuth = provider && provider.requires_openai_auth !== false; + const apiKey = typeof provider.preferred_auth_method === 'string' + ? provider.preferred_auth_method.trim() + : ''; + const authValue = requiresAuth ? apiKey : (apiKey || ''); + const timeoutMs = Number.isFinite(options.timeoutMs) + ? Math.max(1000, Number(options.timeoutMs)) + : DEFAULT_TIMEOUT_MS; + const runProbeJsonPost = typeof options.probeJsonPost === 'function' ? options.probeJsonPost : probeJsonPost; + + let modelProbeSpec = modelProbeSpecs[0]; + let modelProbe = null; + for (let index = 0; index < modelProbeSpecs.length; index += 1) { + const candidate = modelProbeSpecs[index]; + const probeResult = await runProbeJsonPost(candidate.url, candidate.body, { + apiKey: authValue, + timeoutMs, + maxBytes: JSON_RESPONSE_MAX_BYTES + }); + modelProbeSpec = candidate; + modelProbe = probeResult; + const shouldTryNextCandidate = index < modelProbeSpecs.length - 1 + && (!probeResult.ok || probeResult.status === 404); + if (!shouldTryNextCandidate) { + break; + } + } + + summary.checks.modelProbe = { + url: modelProbeSpec.url, + ok: !!modelProbe.ok, + status: Number.isFinite(modelProbe.status) ? modelProbe.status : 0, + durationMs: Number.isFinite(modelProbe.durationMs) ? modelProbe.durationMs : 0 + }; + + if (!modelProbe.ok) { + issues.push({ + code: 'remote-model-probe-unreachable', + message: `模型可用性探测失败:${modelProbe.error || '无法连接'}`, + suggestion: '检查 endpoint、网络或模型接口是否可用' + }); + } else if (modelProbe.status === 401 || modelProbe.status === 403) { + issues.push({ + code: 'remote-model-probe-auth-failed', + message: '模型可用性探测鉴权失败(401/403)', + suggestion: '检查 API Key 或认证方式' + }); + } else if (modelProbe.status === 404) { + issues.push({ + code: 'remote-model-probe-not-found', + message: '模型可用性探测返回 404', + suggestion: '检查 base_url、接口路径或模型名' + }); + } else if (modelProbe.status >= 400) { + issues.push({ + code: 'remote-model-probe-http-error', + message: `模型可用性探测返回异常状态: ${modelProbe.status}`, + suggestion: '检查 endpoint、模型名或服务状态' + }); + } else { + let payload = null; + try { + payload = modelProbe.body ? JSON.parse(modelProbe.body) : null; + } catch (e) { + payload = null; + } + const payloadError = extractPayloadErrorMessage(payload); + if (payloadError) { + issues.push({ + code: 'remote-model-probe-error', + message: `模型可用性探测失败:${payloadError}`, + suggestion: '检查模型名与权限' + }); + } + } + + const primaryIssue = issues[0] || null; + summary.ok = issues.length === 0; + summary.statusCode = Number.isFinite(modelProbe.status) && modelProbe.status > 0 + ? modelProbe.status + : null; + summary.message = buildRemoteHealthMessage( + primaryIssue ? primaryIssue.code : '', + summary.statusCode, + primaryIssue ? primaryIssue.message : '' + ); + + return { issues, remote: summary }; +} + +async function buildConfigHealthReport(params = {}, deps = {}) { + const issues = []; + const { + readConfigOrVirtualDefault, + readModels + } = deps; + + if (typeof readConfigOrVirtualDefault !== 'function') { + throw new Error('buildConfigHealthReport 缺少 readConfigOrVirtualDefault 依赖'); + } + if (typeof readModels !== 'function') { + throw new Error('buildConfigHealthReport 缺少 readModels 依赖'); + } + + const status = readConfigOrVirtualDefault(); + const config = status.config || {}; + + if (status.isVirtual) { + const parseFailed = status.errorType === 'parse'; + const readFailed = status.errorType === 'read'; + issues.push({ + code: parseFailed ? 'config-parse-failed' : (readFailed ? 'config-read-failed' : 'config-missing'), + message: status.reason || (parseFailed + ? 'config.toml 解析失败' + : (readFailed ? '读取 config.toml 失败' : '未检测到 config.toml')), + suggestion: parseFailed + ? '修复 config.toml 语法错误后重试' + : (readFailed ? '检查文件权限后重试' : '在模板编辑器中确认应用配置,生成可用的 config.toml') + }); + if (parseFailed || readFailed) { + return { + ok: false, + issues, + summary: { + currentProvider: '', + currentModel: '' + }, + remote: null + }; + } + } + + const providerName = typeof config.model_provider === 'string' ? config.model_provider.trim() : ''; + const modelName = typeof config.model === 'string' ? config.model.trim() : ''; + if (!providerName) { + issues.push({ + code: 'provider-missing', + message: '当前 provider 未设置', + suggestion: '在模板中设置 model_provider' + }); + } + + if (!modelName) { + issues.push({ + code: 'model-missing', + message: '当前模型未设置', + suggestion: '在模板中设置 model' + }); + } + + const providers = config.model_providers && typeof config.model_providers === 'object' + ? config.model_providers + : {}; + const provider = providerName ? providers[providerName] : null; + if (providerName && !provider) { + issues.push({ + code: 'provider-not-found', + message: `当前 provider 未在配置中找到: ${providerName}`, + suggestion: '检查 model_providers 是否包含该 provider 配置块' + }); + } + + if (provider && typeof provider === 'object') { + const baseUrl = typeof provider.base_url === 'string' ? provider.base_url.trim() : ''; + if (!isValidHttpUrl(baseUrl)) { + issues.push({ + code: 'base-url-invalid', + message: '当前 provider 的 base_url 无效', + suggestion: '请设置为 http/https 的完整 URL' + }); + } + + const requiresAuth = provider.requires_openai_auth; + if (requiresAuth !== false) { + const apiKey = typeof provider.preferred_auth_method === 'string' + ? provider.preferred_auth_method.trim() + : ''; + if (!apiKey) { + issues.push({ + code: 'api-key-missing', + message: '当前 provider 未配置 API Key', + suggestion: '在模板中设置 preferred_auth_method' + }); + } + } + } + + if (modelName) { + const models = readModels(); + if (!models.includes(modelName)) { + issues.push({ + code: 'model-unavailable', + message: `模型未在可用列表中找到: ${modelName}`, + suggestion: '在模型列表中添加该模型或切换到已有模型' + }); + } + } + + let remote = null; + if (params.remote) { + if (!provider) { + issues.push({ + code: 'remote-skip-provider', + message: '无法进行远程探测:provider 未找到', + suggestion: '检查 model_provider 配置或关闭远程探测' + }); + remote = { + type: 'remote-health-check', + provider: providerName, + endpoint: '', + ok: false, + statusCode: null, + message: '无法进行远程探测:provider 未找到', + checks: {} + }; + } else { + const remoteReport = await runRemoteHealthCheck(providerName, provider, modelName, { + timeoutMs: Number.isFinite(params.timeoutMs) ? Number(params.timeoutMs) : undefined, + probeJsonPost: deps.probeJsonPost + }); + issues.push(...remoteReport.issues); + remote = remoteReport.remote; + } + } + + return { + ok: issues.length === 0, + issues, + summary: { + currentProvider: providerName, + currentModel: modelName + }, + remote + }; +} + +module.exports = { + runRemoteHealthCheck, + buildConfigHealthReport +}; diff --git a/lib/cli-models-utils.js b/lib/cli-models-utils.js index a45ddde..df414ab 100644 --- a/lib/cli-models-utils.js +++ b/lib/cli-models-utils.js @@ -82,47 +82,202 @@ function normalizeWireApi(value) { return raw.replace(/[\s\-\/]/g, '_'); } +function buildApiProbeUrlCandidates(baseUrl, pathSuffix) { + const normalized = normalizeBaseUrl(baseUrl); + if (!normalized) return []; + + const safeSuffix = String(pathSuffix || '').replace(/^\/+/g, ''); + if (!safeSuffix) return [normalized]; + + const candidates = []; + const pushUnique = (value) => { + if (value && !candidates.includes(value)) { + candidates.push(value); + } + }; + + let pathname = ''; + try { + pathname = new URL(normalized).pathname.replace(/\/+$/g, ''); + } catch (e) { + pathname = ''; + } + + const directUrl = `${normalized}/${safeSuffix}`; + const versionedUrl = joinApiUrl(normalized, safeSuffix); + if (/\/v\d+$/i.test(pathname)) { + pushUnique(directUrl); + return candidates; + } + + if (!pathname || pathname === '/') { + pushUnique(versionedUrl); + pushUnique(directUrl); + return candidates; + } + + pushUnique(directUrl); + pushUnique(versionedUrl); + return candidates; +} + function buildModelsProbeUrl(baseUrl) { - return joinApiUrl(baseUrl, 'models'); + return buildApiProbeUrlCandidates(baseUrl, 'models')[0] || ''; } -function buildModelProbeSpec(provider, modelName, baseUrl) { +function buildModelProbeSpecs(provider, modelName, baseUrl) { const model = typeof modelName === 'string' ? modelName.trim() : ''; - if (!model) return null; + if (!model) return []; const wireApi = normalizeWireApi(provider && provider.wire_api); + let pathSuffix = 'responses'; + let body = { + model, + input: 'ping', + max_output_tokens: 1 + }; + if (wireApi === 'chat_completions' || wireApi === 'chat') { - return { - url: joinApiUrl(baseUrl, 'chat/completions'), - body: { - model, - messages: [{ role: 'user', content: 'ping' }], - max_tokens: 1, - temperature: 0 - } + pathSuffix = 'chat/completions'; + body = { + model, + messages: [{ role: 'user', content: 'ping' }], + max_tokens: 1, + temperature: 0 + }; + } else if (wireApi === 'completions') { + pathSuffix = 'completions'; + body = { + model, + prompt: 'ping', + max_tokens: 1, + temperature: 0 }; } - if (wireApi === 'completions') { - return { - url: joinApiUrl(baseUrl, 'completions'), - body: { - model, - prompt: 'ping', - max_tokens: 1, - temperature: 0 - } + return buildApiProbeUrlCandidates(baseUrl, pathSuffix).map((url) => ({ + url, + body + })); +} + +function buildModelProbeSpec(provider, modelName, baseUrl) { + return buildModelProbeSpecs(provider, modelName, baseUrl)[0] || null; +} + +function buildModelConversationSpecs(provider, modelName, baseUrl, prompt, options = {}) { + const model = typeof modelName === 'string' ? modelName.trim() : ''; + const userPrompt = typeof prompt === 'string' ? prompt.trim() : ''; + if (!model || !userPrompt) return []; + + const wireApi = normalizeWireApi(provider && provider.wire_api); + const maxOutputTokens = Number.isFinite(options.maxOutputTokens) + ? Math.max(1, Number(options.maxOutputTokens)) + : 256; + let pathSuffix = 'responses'; + let body = { + model, + input: userPrompt, + max_output_tokens: maxOutputTokens + }; + + if (wireApi === 'chat_completions' || wireApi === 'chat') { + pathSuffix = 'chat/completions'; + body = { + model, + messages: [{ role: 'user', content: userPrompt }], + max_tokens: maxOutputTokens + }; + } else if (wireApi === 'completions') { + pathSuffix = 'completions'; + body = { + model, + prompt: userPrompt, + max_tokens: maxOutputTokens }; } - return { - url: joinApiUrl(baseUrl, 'responses'), - body: { - model, - input: 'ping', - max_output_tokens: 1 + return buildApiProbeUrlCandidates(baseUrl, pathSuffix).map((url) => ({ + url, + body, + wireApi + })); +} + +function collectStructuredText(content, pieces) { + if (typeof content === 'string') { + const text = content.trim(); + if (text) pieces.push(text); + return; + } + if (Array.isArray(content)) { + for (const item of content) { + collectStructuredText(item, pieces); } - }; + return; + } + if (!content || typeof content !== 'object') { + return; + } + + if (typeof content.output_text === 'string' && content.output_text.trim()) { + pieces.push(content.output_text.trim()); + } + if (typeof content.text === 'string' && content.text.trim()) { + pieces.push(content.text.trim()); + } + if (typeof content.content === 'string' && content.content.trim()) { + pieces.push(content.content.trim()); + } + + if (Array.isArray(content.content)) { + collectStructuredText(content.content, pieces); + } + if (content.message) { + collectStructuredText(content.message, pieces); + } +} + +function extractModelResponseText(payload) { + if (!payload || typeof payload !== 'object') { + return ''; + } + + if (typeof payload.output_text === 'string' && payload.output_text.trim()) { + return payload.output_text.trim(); + } + + const pieces = []; + + if (Array.isArray(payload.output)) { + for (const item of payload.output) { + collectStructuredText(item, pieces); + } + } + + if (Array.isArray(payload.choices)) { + for (const choice of payload.choices) { + if (!choice || typeof choice !== 'object') continue; + if (typeof choice.text === 'string' && choice.text.trim()) { + pieces.push(choice.text.trim()); + } + if (choice.message) { + collectStructuredText(choice.message, pieces); + } + } + } + + if (payload.message) { + collectStructuredText(payload.message, pieces); + } + if (payload.content) { + collectStructuredText(payload.content, pieces); + } + if (typeof payload.text === 'string' && payload.text.trim()) { + pieces.push(payload.text.trim()); + } + + return Array.from(new Set(pieces)).join('\n\n').trim(); } function hashModelsCacheValue(value) { @@ -145,8 +300,12 @@ module.exports = { hasModelsListPayload, extractModelIds, normalizeWireApi, + buildApiProbeUrlCandidates, buildModelsProbeUrl, + buildModelProbeSpecs, buildModelProbeSpec, + buildModelConversationSpecs, + extractModelResponseText, hashModelsCacheValue, buildModelsCacheKey }; diff --git a/lib/cli-network-utils.js b/lib/cli-network-utils.js index f405853..9ed96dc 100644 --- a/lib/cli-network-utils.js +++ b/lib/cli-network-utils.js @@ -1,58 +1,38 @@ const http = require('http'); const https = require('https'); -function probeUrl(targetUrl, options = {}) { - return new Promise((resolve) => { - let parsed; - try { - parsed = new URL(targetUrl); - } catch (e) { - return resolve({ ok: false, error: 'Invalid URL' }); - } - - const protocol = parsed.protocol; - if (protocol !== 'http:' && protocol !== 'https:') { - return resolve({ - ok: false, - error: `ERR_INVALID_PROTOCOL: Protocol "${protocol}" not supported. Expected "http:" or "https:"` - }); - } - - const transport = protocol === 'https:' ? https : http; - const headers = { - 'User-Agent': 'codexmate-health-check', - 'Accept': 'application/json' - }; - if (options.apiKey) { - headers['Authorization'] = `Bearer ${options.apiKey}`; - } +function shouldRetryWithIpv4(result) { + if (!result || result.ok || typeof result.error !== 'string') { + return false; + } + return /timeout|timed out|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH|EAI_AGAIN/i.test(result.error); +} - const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 0; - const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : 256 * 1024; +function performProbeRequest(transport, parsed, requestOptions, options = {}) { + return new Promise((resolve) => { const start = Date.now(); - const req = transport.request(parsed, { method: 'GET', headers }, (res) => { + const req = transport.request(parsed, requestOptions, (res) => { const chunks = []; let size = 0; res.on('data', (chunk) => { if (!chunk) return; size += chunk.length; - if (size <= maxBytes) { + if (size <= options.maxBytes) { chunks.push(chunk); } }); res.on('end', () => { - const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf-8') : ''; resolve({ ok: true, status: res.statusCode || 0, durationMs: Date.now() - start, - body + body: chunks.length > 0 ? Buffer.concat(chunks).toString('utf-8') : '' }); }); }); - if (timeoutMs > 0) { - req.setTimeout(timeoutMs, () => { + if (options.timeoutMs > 0) { + req.setTimeout(options.timeoutMs, () => { req.destroy(new Error('timeout')); }); } @@ -65,81 +45,117 @@ function probeUrl(targetUrl, options = {}) { }); }); + if (options.payload) { + req.write(options.payload); + } req.end(); }); } -function probeJsonPost(targetUrl, body, options = {}) { - return new Promise((resolve) => { - let parsed; - try { - parsed = new URL(targetUrl); - } catch (e) { - return resolve({ ok: false, error: 'Invalid URL' }); - } - - const protocol = parsed.protocol; - if (protocol !== 'http:' && protocol !== 'https:') { - return resolve({ - ok: false, - error: `ERR_INVALID_PROTOCOL: Protocol "${protocol}" not supported. Expected "http:" or "https:"` - }); - } - - const transport = protocol === 'https:' ? https : http; - const headers = { - 'User-Agent': 'codexmate-health-check', - 'Accept': 'application/json', - 'Content-Type': 'application/json' +async function probeUrl(targetUrl, options = {}) { + let parsed; + try { + parsed = new URL(targetUrl); + } catch (e) { + return { ok: false, error: 'Invalid URL' }; + } + + const protocol = parsed.protocol; + if (protocol !== 'http:' && protocol !== 'https:') { + return { + ok: false, + error: `ERR_INVALID_PROTOCOL: Protocol "${protocol}" not supported. Expected "http:" or "https:"` }; - if (options.apiKey) { - headers['Authorization'] = `Bearer ${options.apiKey}`; - } - - const payload = JSON.stringify(body || {}); - headers['Content-Length'] = Buffer.byteLength(payload); - - const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 0; - const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : 256 * 1024; - const start = Date.now(); - const req = transport.request(parsed, { method: 'POST', headers }, (res) => { - const chunks = []; - let size = 0; - res.on('data', (chunk) => { - if (!chunk) return; - size += chunk.length; - if (size <= maxBytes) { - chunks.push(chunk); - } - }); - res.on('end', () => { - const bodyText = chunks.length > 0 ? Buffer.concat(chunks).toString('utf-8') : ''; - resolve({ - ok: true, - status: res.statusCode || 0, - durationMs: Date.now() - start, - body: bodyText - }); - }); - }); - - if (timeoutMs > 0) { - req.setTimeout(timeoutMs, () => { - req.destroy(new Error('timeout')); - }); - } - - req.on('error', (err) => { - resolve({ - ok: false, - error: err.message || 'request failed', - durationMs: Date.now() - start - }); - }); + } + + const transport = protocol === 'https:' ? https : http; + const headers = { + 'User-Agent': 'codexmate-health-check', + 'Accept': 'application/json' + }; + if (options.apiKey) { + headers['Authorization'] = `Bearer ${options.apiKey}`; + } + + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 0; + const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : 256 * 1024; + const requestOptions = { + method: 'GET', + headers + }; + + const firstResult = await performProbeRequest(transport, parsed, requestOptions, { + timeoutMs, + maxBytes + }); + if (!shouldRetryWithIpv4(firstResult)) { + return firstResult; + } + + const secondResult = await performProbeRequest(transport, parsed, { + ...requestOptions, + family: 4 + }, { + timeoutMs, + maxBytes + }); + return secondResult.ok ? secondResult : firstResult; +} - req.write(payload); - req.end(); +async function probeJsonPost(targetUrl, body, options = {}) { + let parsed; + try { + parsed = new URL(targetUrl); + } catch (e) { + return { ok: false, error: 'Invalid URL' }; + } + + const protocol = parsed.protocol; + if (protocol !== 'http:' && protocol !== 'https:') { + return { + ok: false, + error: `ERR_INVALID_PROTOCOL: Protocol "${protocol}" not supported. Expected "http:" or "https:"` + }; + } + + const transport = protocol === 'https:' ? https : http; + const headers = { + 'User-Agent': 'codexmate-health-check', + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + if (options.apiKey) { + headers['Authorization'] = `Bearer ${options.apiKey}`; + } + + const payload = JSON.stringify(body || {}); + headers['Content-Length'] = Buffer.byteLength(payload); + + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 0; + const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : 256 * 1024; + const requestOptions = { + method: 'POST', + headers + }; + + const firstResult = await performProbeRequest(transport, parsed, requestOptions, { + timeoutMs, + maxBytes, + payload + }); + if (!shouldRetryWithIpv4(firstResult)) { + return firstResult; + } + + const secondResult = await performProbeRequest(transport, parsed, { + ...requestOptions, + family: 4 + }, { + timeoutMs, + maxBytes, + payload }); + return secondResult.ok ? secondResult : firstResult; } module.exports = { diff --git a/package.json b/package.json index bce7c98..9524335 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "files": [ "cli.js", + "cli/", "web-ui.html", "lib/", "web-ui/", diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js index 0985c51..1602b7e 100644 --- a/tests/e2e/helpers.js +++ b/tests/e2e/helpers.js @@ -4,7 +4,12 @@ const http = require('http'); const os = require('os'); const { spawnSync, spawn } = require('child_process'); const { writeJsonAtomic } = require('../../lib/cli-file-utils'); -const { normalizeWireApi, buildModelProbeSpec } = require('../../lib/cli-models-utils'); +const { + normalizeWireApi, + buildModelProbeSpec, + buildModelConversationSpecs, + extractModelResponseText +} = require('../../lib/cli-models-utils'); const debug = (...args) => { if (process.env.E2E_DEBUG) { @@ -149,9 +154,16 @@ function startLocalServer(options = {}) { const mode = options.mode || 'list'; const modelsPath = options.modelsPath || '/models'; const status = options.status || 200; + const responseBody = options.responseBody || { ok: true }; + const responsePaths = Array.isArray(options.responsePaths) + ? options.responsePaths.map(item => String(item || '')) + : null; + const requests = []; return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { - if (req.url && req.url.startsWith(modelsPath)) { + const requestPath = String(req.url || '').split('?')[0]; + requests.push(requestPath); + if (requestPath && requestPath.startsWith(modelsPath)) { if (mode === 'none') { const errorBody = JSON.stringify({ error: 'not found' }); res.writeHead(404, { @@ -183,7 +195,16 @@ function startLocalServer(options = {}) { res.end(jsonBody, 'utf-8'); return; } - const okBody = JSON.stringify({ ok: true }); + if (responsePaths && !responsePaths.includes(requestPath)) { + const errorBody = JSON.stringify({ error: 'not found' }); + res.writeHead(404, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(errorBody, 'utf-8') + }); + res.end(errorBody, 'utf-8'); + return; + } + const okBody = JSON.stringify(responseBody); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(okBody, 'utf-8') @@ -193,7 +214,7 @@ function startLocalServer(options = {}) { server.on('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); - resolve({ server, port: address.port }); + resolve({ server, port: address.port, requests }); }); }); } @@ -226,5 +247,7 @@ module.exports = { closeServer, writeJsonAtomic, normalizeWireApi, - buildModelProbeSpec + buildModelProbeSpec, + buildModelConversationSpecs, + extractModelResponseText }; diff --git a/tests/e2e/recent-health.e2e.js b/tests/e2e/recent-health.e2e.js index 1865c87..bf591ff 100644 --- a/tests/e2e/recent-health.e2e.js +++ b/tests/e2e/recent-health.e2e.js @@ -4,8 +4,11 @@ const os = require('os'); const fs = require('fs'); const { spawn } = require('child_process'); -const PORT = 3737; -const API_URL = `http://localhost:${PORT}/api`; +let g_port = 3737; + +function getApiUrl() { + return `http://localhost:${g_port}/api`; +} function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); @@ -14,7 +17,7 @@ function delay(ms) { function request(action, params = {}) { const payload = JSON.stringify({ action, params }); return new Promise((resolve, reject) => { - const req = http.request(API_URL, { + const req = http.request(getApiUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -74,12 +77,14 @@ function assert(condition, message) { } async function run() { + g_port = 18000 + Math.floor(Math.random() * 1000); const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-e2e-')); const env = { ...process.env, USERPROFILE: tempHome, HOME: tempHome, - CODEXMATE_NO_BROWSER: '1' + CODEXMATE_NO_BROWSER: '1', + CODEXMATE_PORT: String(g_port) }; const cliPath = path.join(__dirname, '..', '..', 'cli.js'); @@ -120,13 +125,19 @@ async function run() { assert(codes.has('model-unavailable'), 'health check should flag missing model'); await request('apply-config-template', { - template: buildTemplate('local', 'm-local', `http://127.0.0.1:${PORT}`, 'sk-local') + template: buildTemplate('local', 'm-local', `http://127.0.0.1:${g_port}`, 'sk-local') }); const remoteHealth = await request('config-health-check', { remote: true, timeoutMs: 2000 }); assert(remoteHealth && Array.isArray(remoteHealth.issues), 'remote health issues should be array'); assert(remoteHealth.ok === true, 'remote health should pass'); - assert(remoteHealth.remote && remoteHealth.remote.type === 'speed-test', 'remote health should include speed-test info'); - assert(typeof remoteHealth.remote.durationMs === 'number', 'remote health should include duration'); + assert(remoteHealth.remote && remoteHealth.remote.type === 'remote-health-check', 'remote health should include remote-health-check info'); + assert(remoteHealth.remote.statusCode === 200, 'remote health should expose statusCode'); + assert( + remoteHealth.remote.checks && + remoteHealth.remote.checks.modelProbe && + typeof remoteHealth.remote.checks.modelProbe.durationMs === 'number', + 'remote health should include model probe duration' + ); } finally { if (child && !child.killed) { child.kill('SIGINT'); diff --git a/tests/e2e/run.js b/tests/e2e/run.js index b81c320..9c6c618 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -55,17 +55,27 @@ async function main() { let noModelsProvider; let htmlModelsProvider; let authFailProvider; + let routedProvider; let webServer; try { mockProvider = await startLocalServer({ mode: 'list', modelsPath: '/v1/models' }); noModelsProvider = await startLocalServer({ mode: 'none', modelsPath: '/v1/models' }); htmlModelsProvider = await startLocalServer({ mode: 'html', modelsPath: '/v1/models' }); authFailProvider = await startLocalServer({ mode: 'list', modelsPath: '/v1/models', status: 401 }); + routedProvider = await startLocalServer({ + mode: 'list', + modelsPath: '/project/ym/models', + responsePaths: ['/project/ym/responses'], + responseBody: { + output_text: 'routed provider is healthy' + } + }); const mockProviderUrl = `http://127.0.0.1:${mockProvider.port}`; const noModelsUrl = `http://127.0.0.1:${noModelsProvider.port}`; const htmlModelsUrl = `http://127.0.0.1:${htmlModelsProvider.port}`; const authFailUrl = `http://127.0.0.1:${authFailProvider.port}`; + const routedProviderUrl = `http://127.0.0.1:${routedProvider.port}/project/ym`; const ctx = { env, @@ -75,7 +85,9 @@ async function main() { mockProviderUrl, noModelsUrl, htmlModelsUrl, - authFailUrl + authFailUrl, + routedProviderUrl, + routedProviderRequests: routedProvider.requests }; await testSetup(ctx); @@ -149,6 +161,7 @@ async function main() { await closeServer(noModelsProvider && noModelsProvider.server); await closeServer(htmlModelsProvider && htmlModelsProvider.server); await closeServer(authFailProvider && authFailProvider.server); + await closeServer(routedProvider && routedProvider.server); for (const state of realFileStates) { const label = state && state.path ? path.basename(state.path) : 'real file'; diff --git a/tests/e2e/test-health-speed.js b/tests/e2e/test-health-speed.js index 67fe3d6..aa69917 100644 --- a/tests/e2e/test-health-speed.js +++ b/tests/e2e/test-health-speed.js @@ -3,12 +3,14 @@ const { assert, normalizeWireApi, buildModelProbeSpec, + buildModelConversationSpecs, + extractModelResponseText, fileMode, writeJsonAtomic } = require('./helpers'); module.exports = async function testHealthAndSpeed(ctx) { - const { api, mockProviderUrl, authFailUrl, tmpHome } = ctx; + const { api, mockProviderUrl, authFailUrl, routedProviderUrl, routedProviderRequests, tmpHome } = ctx; // ========== Speed Test Tests - Provider ========== const speedResult = await api('speed-test', { name: 'e2e2' }); @@ -56,6 +58,7 @@ module.exports = async function testHealthAndSpeed(ctx) { const healthRemote = await api('config-health-check', { remote: true }); assert('ok' in healthRemote, 'config-health-check(remote) missing ok'); assert('remote' in healthRemote, 'config-health-check(remote) missing remote'); + assert(healthRemote.remote.type === 'remote-health-check', 'config-health-check(remote) should use remote-health-check'); // ========== Config Health Check Tests - Invalid Config ========== const configPath = path.join(tmpHome, '.codex', 'config.toml'); @@ -105,6 +108,81 @@ module.exports = async function testHealthAndSpeed(ctx) { const probeSpecDefault = buildModelProbeSpec({}, 'e2e-default', mockProviderUrl); assert(probeSpecDefault && probeSpecDefault.url.endsWith('/responses'), 'buildModelProbeSpec should default to responses endpoint'); + const probeSpecRouted = buildModelProbeSpec({ wire_api: 'responses' }, 'e2e-routed', routedProviderUrl); + assert( + probeSpecRouted && probeSpecRouted.url === `${routedProviderUrl}/responses`, + 'buildModelProbeSpec should keep direct provider routes ahead of /v1 fallback' + ); + + const routedConfig = [ + 'model_provider = "routed"', + 'model = "e2e-model"', + '', + '[model_providers.routed]', + `base_url = "${routedProviderUrl}"`, + 'wire_api = "responses"', + 'preferred_auth_method = "sk-routed"', + '' + ].join('\n'); + require('fs').writeFileSync(configPath, routedConfig, 'utf-8'); + routedProviderRequests.length = 0; + const routedHealth = await api('config-health-check', { remote: true, timeoutMs: 3000 }); + assert(routedHealth.ok === true, 'health-check(remote) should pass for direct provider route'); + assert( + routedHealth.remote && + routedHealth.remote.checks && + routedHealth.remote.checks.modelProbe && + routedHealth.remote.checks.modelProbe.url === `${routedProviderUrl}/responses`, + 'health-check(remote) should probe the direct responses path' + ); + assert( + routedProviderRequests.includes('/project/ym/responses'), + 'health-check(remote) should hit the direct routed responses endpoint' + ); + assert( + !routedProviderRequests.includes('/project/ym/v1/responses'), + 'health-check(remote) should not inject /v1 before direct routed responses' + ); + + routedProviderRequests.length = 0; + const routedSpeed = await api('speed-test', { name: 'routed' }); + assert(routedSpeed.ok === true, 'speed-test(provider) should pass for direct provider route'); + assert( + routedProviderRequests.includes('/project/ym/responses'), + 'speed-test(provider) should hit the direct routed responses endpoint' + ); + assert( + !routedProviderRequests.includes('/project/ym/v1/responses'), + 'speed-test(provider) should not inject /v1 before direct routed responses' + ); + + const conversationSpecs = buildModelConversationSpecs({ wire_api: 'responses' }, 'e2e-routed', routedProviderUrl, 'hello'); + assert(Array.isArray(conversationSpecs) && conversationSpecs.length > 0, 'buildModelConversationSpecs should build candidate endpoints'); + assert( + conversationSpecs[0].url === `${routedProviderUrl}/responses`, + 'buildModelConversationSpecs should keep direct provider routes ahead of /v1 fallback' + ); + + const conversationResult = await api('provider-chat-check', { + name: 'routed', + prompt: '请回复连接正常' + }, 5000); + assert(conversationResult.ok === true, 'provider-chat-check should succeed'); + assert(conversationResult.reply === 'routed provider is healthy', 'provider-chat-check should parse assistant text'); + assert( + routedProviderRequests.includes('/project/ym/responses'), + 'provider-chat-check should hit the direct routed responses endpoint' + ); + + const parsedText = extractModelResponseText({ + choices: [{ + message: { + content: [{ type: 'text', text: 'chat completion ok' }] + } + }] + }); + assert(parsedText === 'chat completion ok', 'extractModelResponseText should parse chat completion content'); + // ========== File Permission Tests ========== const permDir = require('fs').mkdtempSync(path.join(tmpHome, 'perm-')); const existingPath = path.join(permDir, 'secret.json'); diff --git a/tests/unit/agents-modal-guards.test.mjs b/tests/unit/agents-modal-guards.test.mjs index 396ab7e..a302d6a 100644 --- a/tests/unit/agents-modal-guards.test.mjs +++ b/tests/unit/agents-modal-guards.test.mjs @@ -180,6 +180,133 @@ test('runHealthCheck skips Claude speed tests when the primary health check alre }]); }); +test('runHealthCheck preserves backend remote health result while appending speed test summaries', async () => { + const methods = createCodexConfigMethods({ + api: async () => ({ + ok: true, + issues: [], + remote: { + type: 'remote-health-check', + provider: 'alpha', + endpoint: 'https://example.com/v1', + statusCode: 200, + ok: true, + message: 'ok' + } + }), + getProviderConfigModeMeta() { + return null; + } + }); + const context = { + ...methods, + providersList: ['alpha', 'beta'], + speedResults: {}, + speedLoading: {}, + healthCheckLoading: false, + healthCheckResult: null, + configMode: 'codex', + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + async runSpeedTest(name) { + return { ok: true, durationMs: name === 'alpha' ? 10 : 20, status: 200 }; + }, + buildSpeedTestIssue() { + return null; + } + }; + + await methods.runHealthCheck.call(context); + + assert.strictEqual(context.healthCheckLoading, false); + assert.strictEqual(context.healthCheckResult.remote.type, 'remote-health-check'); + assert.strictEqual(context.healthCheckResult.remote.statusCode, 200); + assert.deepStrictEqual(context.healthCheckResult.remote.speedTests, { + alpha: { ok: true, durationMs: 10, status: 200 }, + beta: { ok: true, durationMs: 20, status: 200 } + }); +}); + +test('openHealthCheckDialog opens unlocked selector by default and locks when provider is specified', () => { + const methods = createCodexConfigMethods({ + api: async () => ({}), + getProviderConfigModeMeta() { + return null; + } + }); + const context = { + ...methods, + currentProvider: 'alpha', + displayProvidersList: [{ name: 'alpha' }, { name: 'beta' }], + showHealthCheckDialog: false, + healthCheckDialogLockedProvider: '', + healthCheckDialogSelectedProvider: '', + healthCheckDialogPrompt: '', + healthCheckDialogMessages: [{ id: 'stale' }], + healthCheckDialogLastResult: { ok: false } + }; + + methods.openHealthCheckDialog.call(context); + assert.strictEqual(context.showHealthCheckDialog, true); + assert.strictEqual(context.healthCheckDialogLockedProvider, ''); + assert.strictEqual(context.healthCheckDialogSelectedProvider, 'alpha'); + assert.deepStrictEqual(context.healthCheckDialogMessages, []); + + methods.openHealthCheckDialog.call(context, { providerName: 'beta', locked: true }); + assert.strictEqual(context.healthCheckDialogLockedProvider, 'beta'); + assert.strictEqual(context.healthCheckDialogSelectedProvider, 'beta'); +}); + +test('sendHealthCheckDialogMessage appends transcript and clears prompt after success', async () => { + const apiCalls = []; + const methods = createCodexConfigMethods({ + api: async (action, params) => { + apiCalls.push({ action, params }); + return { + ok: true, + provider: params.name, + model: 'alpha-model', + status: 200, + durationMs: 12, + reply: 'provider is healthy' + }; + }, + getProviderConfigModeMeta() { + return null; + } + }); + const context = { + ...methods, + healthCheckDialogLockedProvider: '', + healthCheckDialogSelectedProvider: 'alpha', + healthCheckDialogPrompt: 'say ok', + healthCheckDialogMessages: [], + healthCheckDialogSending: false, + healthCheckDialogLastResult: null, + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + } + }; + + await methods.sendHealthCheckDialogMessage.call(context); + + assert.deepStrictEqual(apiCalls, [{ + action: 'provider-chat-check', + params: { + name: 'alpha', + prompt: 'say ok' + } + }]); + assert.strictEqual(context.healthCheckDialogPrompt, ''); + assert.strictEqual(context.healthCheckDialogSending, false); + assert.strictEqual(context.healthCheckDialogMessages.length, 2); + assert.strictEqual(context.healthCheckDialogMessages[0].role, 'user'); + assert.strictEqual(context.healthCheckDialogMessages[1].text, 'provider is healthy'); +}); + test('applyCodexConfigDirect keeps the successful apply result when only the refresh fails', async () => { const apiCalls = []; const methods = createCodexConfigMethods({ diff --git a/tests/unit/cli-network-utils.test.mjs b/tests/unit/cli-network-utils.test.mjs new file mode 100644 index 0000000..e2d0fb9 --- /dev/null +++ b/tests/unit/cli-network-utils.test.mjs @@ -0,0 +1,63 @@ +import assert from 'assert'; +import path from 'path'; +import { EventEmitter } from 'events'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); + +const https = require('https'); +const { probeJsonPost } = require(path.join(__dirname, '..', '..', 'lib', 'cli-network-utils.js')); + +test('probeJsonPost retries with family=4 after a retryable network timeout', async () => { + const originalRequest = https.request; + const calls = []; + + try { + https.request = (parsed, options, callback) => { + calls.push({ + host: parsed.hostname, + family: options.family || 0, + method: options.method + }); + + const req = new EventEmitter(); + req.setTimeout = () => {}; + req.write = () => {}; + req.end = () => { + process.nextTick(() => { + if (!options.family) { + req.emit('error', new Error('timeout')); + return; + } + + const res = new EventEmitter(); + res.statusCode = 200; + callback(res); + process.nextTick(() => { + res.emit('data', Buffer.from('{"ok":true}')); + res.emit('end'); + }); + }); + }; + return req; + }; + + const result = await probeJsonPost('https://example.com/responses', { ping: true }, { + timeoutMs: 1000, + maxBytes: 1024 + }); + + assert.deepStrictEqual(calls, [ + { host: 'example.com', family: 0, method: 'POST' }, + { host: 'example.com', family: 4, method: 'POST' } + ]); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body, '{"ok":true}'); + } finally { + https.request = originalRequest; + } +}); diff --git a/tests/unit/config-health-module.test.mjs b/tests/unit/config-health-module.test.mjs new file mode 100644 index 0000000..dc176f7 --- /dev/null +++ b/tests/unit/config-health-module.test.mjs @@ -0,0 +1,155 @@ +import assert from 'assert'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); + +const { + runRemoteHealthCheck, + buildConfigHealthReport +} = require(path.join(__dirname, '..', '..', 'cli', 'config-health.js')); +const { + buildModelProbeSpec, + buildModelProbeSpecs +} = require(path.join(__dirname, '..', '..', 'lib', 'cli-models-utils.js')); + +test('runRemoteHealthCheck returns ai-healthcheck style success summary on 200', async () => { + const result = await runRemoteHealthCheck('openai', { + base_url: 'https://api.openai.com/v1', + preferred_auth_method: 'sk-demo', + wire_api: 'responses' + }, 'gpt-5-mini', { + probeJsonPost: async () => ({ + ok: true, + status: 200, + durationMs: 12, + body: '{"id":"resp_1"}' + }) + }); + + assert.deepStrictEqual(result.issues, []); + assert.strictEqual(result.remote.type, 'remote-health-check'); + assert.strictEqual(result.remote.provider, 'openai'); + assert.strictEqual(result.remote.endpoint, 'https://api.openai.com/v1'); + assert.strictEqual(result.remote.ok, true); + assert.strictEqual(result.remote.statusCode, 200); + assert.match(result.remote.message, /远程模型探测通过/); + assert.strictEqual(result.remote.checks.modelProbe.url, 'https://api.openai.com/v1/responses'); +}); + +test('runRemoteHealthCheck reports auth failure with stable summary fields', async () => { + const result = await runRemoteHealthCheck('openai', { + base_url: 'https://api.openai.com', + preferred_auth_method: 'sk-demo', + wire_api: 'chat/completions' + }, 'gpt-5-mini', { + probeJsonPost: async () => ({ + ok: true, + status: 401, + durationMs: 8, + body: '{"error":{"message":"Unauthorized"}}' + }) + }); + + assert.strictEqual(result.remote.ok, false); + assert.strictEqual(result.remote.statusCode, 401); + assert.match(result.remote.message, /401\/403/); + assert( + result.issues.some((issue) => issue.code === 'remote-model-probe-auth-failed'), + 'expected auth failure issue code' + ); +}); + +test('runRemoteHealthCheck falls back to a direct responses path when base_url already contains a provider route', async () => { + const seenUrls = []; + const result = await runRemoteHealthCheck('maxx', { + base_url: 'https://maxx-direct.cloverstd.com/project/ym', + preferred_auth_method: 'maxx-demo', + wire_api: 'responses' + }, 'gpt-5.4', { + probeJsonPost: async (url) => { + seenUrls.push(url); + if (url.endsWith('/project/ym/responses')) { + return { + ok: true, + status: 200, + durationMs: 6, + body: '{"id":"resp_ok"}' + }; + } + return { + ok: false, + error: 'timeout', + durationMs: 12, + body: '' + }; + } + }); + + assert.deepStrictEqual(seenUrls, ['https://maxx-direct.cloverstd.com/project/ym/responses']); + assert.strictEqual(result.remote.ok, true); + assert.strictEqual(result.remote.statusCode, 200); + assert.strictEqual(result.remote.checks.modelProbe.url, 'https://maxx-direct.cloverstd.com/project/ym/responses'); + assert.deepStrictEqual(result.issues, []); +}); + +test('buildModelProbeSpecs keeps direct provider routes ahead of injected /v1 fallback', () => { + const specs = buildModelProbeSpecs( + { wire_api: 'responses' }, + 'gpt-5.4', + 'https://maxx-direct.cloverstd.com/project/ym' + ); + assert.deepStrictEqual( + specs.map((item) => item.url), + [ + 'https://maxx-direct.cloverstd.com/project/ym/responses', + 'https://maxx-direct.cloverstd.com/project/ym/v1/responses' + ] + ); + assert.strictEqual( + buildModelProbeSpec( + { wire_api: 'responses' }, + 'gpt-5.4', + 'https://maxx-direct.cloverstd.com/project/ym' + ).url, + 'https://maxx-direct.cloverstd.com/project/ym/responses' + ); +}); + +test('buildConfigHealthReport includes remote-health-check result when remote mode is enabled', async () => { + const report = await buildConfigHealthReport({ remote: true }, { + readConfigOrVirtualDefault() { + return { + isVirtual: false, + config: { + model_provider: 'alpha', + model: 'alpha-model', + model_providers: { + alpha: { + base_url: 'https://example.com', + preferred_auth_method: 'sk-alpha', + wire_api: 'responses' + } + } + } + }; + }, + readModels() { + return ['alpha-model']; + }, + probeJsonPost: async () => ({ + ok: true, + status: 200, + durationMs: 5, + body: '{"ok":true}' + }) + }); + + assert.strictEqual(report.ok, true); + assert.strictEqual(report.remote.type, 'remote-health-check'); + assert.strictEqual(report.remote.statusCode, 200); + assert.deepStrictEqual(report.issues, []); +}); diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index d2afa8d..5903b5a 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -9,6 +9,7 @@ import { test('config template keeps expected config tabs in top and side navigation', () => { const html = readBundledWebUiHtml(); const modalsBasic = readProjectFile('web-ui/partials/index/modals-basic.html'); + const healthCheckModal = readProjectFile('web-ui/partials/index/modal-health-check.html'); const templateAgentModals = readProjectFile('web-ui/partials/index/modal-config-template-agents.html'); const openclawModal = readProjectFile('web-ui/partials/index/modal-openclaw-config.html'); const sessionsPanel = readProjectFile('web-ui/partials/index/panel-sessions.html'); @@ -31,7 +32,7 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /onConfigTabPointerDown\('codex', \$event\)/); assert.match(html, /onMainTabClick\('sessions', \$event\)/); assert.match(html, /onConfigTabClick\('codex', \$event\)/); - assert.match(html, /上下文压缩阈值<\/span>/); + assert.match(html, /压缩阈值<\/span>/); assert.match(html, /v-model="modelContextWindowInput"/); assert.match(html, /v-model="modelAutoCompactTokenLimitInput"/); assert.match(html, /@focus="editingCodexBudgetField = 'modelContextWindowInput'"/); @@ -43,7 +44,7 @@ test('config template keeps expected config tabs in top and side navigation', () assert.doesNotMatch(html, /使用自定义数字输入框;失焦或回车后会按当前 Codex 配置规范写入模板。/); assert.match( html, - /]*@click="resetCodexContextBudgetDefaults"[^>]*>[\s\S]*?重置默认值[\s\S]*?<\/button>/ + /]*@click="resetCodexContextBudgetDefaults"[^>]*>[\s\S]*?重置[\s\S]*?<\/button>/ ); assert.match(html, /class="codex-config-grid"/); assert.match(html, /onSettingsTabClick\('backup'\)/); @@ -60,6 +61,8 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /:aria-selected="mainTab === 'market'"/); assert.match(html, /id="panel-market"/); assert.match(html, /v-show="mainTab === 'market'"/); + assert.doesNotMatch(html, /Skills<\/span>/); + assert.doesNotMatch(html, /openSkillsManager\(\{ targetApp: 'codex' \}\)/); assert.match(html, /loadSkillsMarketOverview\(\{ forceRefresh: true, silent: false \}\)/); assert.match(html, /class="market-grid"/); assert.match(html, /class="market-action-grid"/); @@ -171,10 +174,10 @@ test('config template keeps expected config tabs in top and side navigation', () ); assert(providerShareButton, 'provider share button should exist'); assert.match(providerShareButton[0], /disabled/); - assert.match(providerShareButton[0], /title="分享导入命令(暂时禁用)"/); + assert.match(providerShareButton[0], /title="分享命令(暂不可用)"/); assert.match(html, / + + + + diff --git a/web-ui/partials/index/panel-config-codex.html b/web-ui/partials/index/panel-config-codex.html index 0fe00a4..f8c62ac 100644 --- a/web-ui/partials/index/panel-config-codex.html +++ b/web-ui/partials/index/panel-config-codex.html @@ -10,7 +10,7 @@ - 添加提供商 + 新增提供商 @@ -40,42 +40,42 @@ :placeholder="activeProviderModelPlaceholder" >
- 当前提供商未提供模型列表,视为不限。模型可手动输入。 + 当前无模型列表,可手填。
- 模型列表获取失败,请检查接口或手动输入。 + 模型列表获取失败,可手填。
- {{ isCodexConfigMode ? '当前模型不在接口列表中,请手动输入或在模板中调整。' : '当前模型不在接口列表中,请手动输入。' }} + {{ isCodexConfigMode ? '当前模型不在列表,可手填或改模板。' : '当前模型不在列表,可手填。' }}
- Codex 配置需先改模板,再手动应用。 + 先改模板,再应用。
- {{ activeProviderBridgeHint }} 模板仅在 Codex 模式下可编辑。 + {{ activeProviderBridgeHint }} 模板仅限 Codex 编辑。