diff --git a/README.md b/README.md index 09dd32fa..3f9e8bf4 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**: - **OpenAI-Compatible Bridge**: Use Codex with any OpenAI-compatible UI by normalizing the Responses API; the built-in Codex conversion also fills and normalizes Codex fingerprint headers such as `User-Agent`, `Version`, `OpenAI-Beta`, and `Originator` so upstream providers see an official Codex CLI-shaped request. - **Claude Provider Bridge**: Connect Claude Code to OpenAI Chat Completions-compatible providers and Ollama through the built-in local Claude-compatible proxy. - **OpenCode Provider Control**: Manage OpenCode provider/model selection with a CodexMate-owned provider store under `~/.codexmate`, projecting only the active provider into native OpenCode config to avoid polluting or deleting user-owned settings. +- **Provider Health Cleanup**: Probe local Codex and Claude provider routes, surface failed configs in one modal, and bulk-clean selected broken providers without touching healthy or protected entries. - **Skills Marketplace**: A local-first market to share and import skills between different agent apps. - **Prompt File Editor**: Unified editor for global and project-level `CLAUDE.md` and `AGENTS.md` with auto-detection of project paths. - **Task Orchestrator**: Plan and execute complex tasks with dependency tracking. @@ -77,6 +78,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**: | **OpenAI Bridge** | ✅ | Convert Codex Responses API to standard OpenAI format and attach/normalize Codex fingerprints in the built-in conversion | | **Claude Provider Bridge** | ✅ | Connect Claude Code to OpenAI Chat Completions-compatible providers and Ollama via the built-in Claude-compatible proxy | | **OpenCode Provider Store** | ✅ | Keep multiple OpenCode providers in `~/.codexmate` while projecting only the selected provider to native OpenCode config | +| **Provider Health Check** | ✅ | Probe local Codex/Claude provider routes, highlight failed configs, and bulk-remove selected broken providers safely | | **Prompt Templates** | ✅ | Reusable prompt plugins with variables | | **Prompt File Editor** | ✅ | Edit global and project-level CLAUDE.md / AGENTS.md with auto-detect and path switching | | **MCP Integration** | ✅ | Expose local tools and resources via MCP stdio | diff --git a/README.vi.md b/README.vi.md index 2eb4c4bd..ddfbf777 100644 --- a/README.vi.md +++ b/README.vi.md @@ -48,6 +48,7 @@ Bạn có bao giờ cảm thấy rối khi phải quản lý nhiều AI agent c Khác với các wrapper đơn giản, Codex Mate hoạt động như một **Agent Bridge cục bộ**: - **Trình duyệt phiên thống nhất**: Tìm kiếm và xuất phiên làm việc từ tất cả tool trong một nơi duy nhất. - **Bridge tương thích OpenAI**: Dùng Codex với bất kỳ UI nào hỗ trợ OpenAI bằng cách chuẩn hóa Responses API. +- **Dọn dẹp provider lỗi**: Kiểm tra route provider cục bộ của Codex/Claude, hiển thị cấu hình lỗi trong một modal và xóa hàng loạt các provider hỏng đã chọn mà không chạm vào mục khỏe mạnh hoặc được bảo vệ. - **Chợ skill cục bộ**: Chia sẻ và nhập skill giữa các app agent khác nhau. - **Điều phối tác vụ**: Lập kế hoạch và thực thi tác vụ phức tạp với theo dõi phụ thuộc. @@ -64,6 +65,7 @@ Khác với các wrapper đơn giản, Codex Mate hoạt động như một **Ag | **Chợ skill cục bộ** | ✅ | Import/export skill giữa các app agent | | **Hàng đợi tác vụ** | ✅ | Thực thi tác vụ theo DAG và xem log | | **Bridge OpenAI** | ✅ | Chuyển đổi Codex Responses API sang định dạng OpenAI chuẩn | +| **Kiểm tra sức khỏe Provider** | ✅ | Kiểm tra route provider Codex/Claude, đánh dấu cấu hình lỗi và xóa hàng loạt các provider hỏng đã chọn một cách an toàn | | **Mẫu Prompt** | ✅ | Plugin prompt tái sử dụng được với hỗ trợ biến | | **Tích hợp MCP** | ✅ | Expose tool và resource cục bộ qua MCP stdio | | **Tự động cập nhật** | ✅ | Cập nhật nhanh qua `codexmate update` | diff --git a/README.zh.md b/README.zh.md index f6bdeec0..f692b554 100644 --- a/README.zh.md +++ b/README.zh.md @@ -57,6 +57,7 @@ - **OpenAI 兼容桥接**:通过归一化 Responses API,让 Codex 能够与任何支持 OpenAI 格式的 UI 配合使用;内建 Codex 转换会补齐并规范化 `User-Agent`、`Version`、`OpenAI-Beta`、`Originator` 等 Codex 指纹,对上游伪装为官方 Codex CLI 请求。 - **Claude Provider 桥接**:通过内建本地 Claude 兼容代理,让 Claude Code 接入 OpenAI Chat Completions 兼容 provider 与 Ollama。 - **OpenCode Provider 控制**:在 `~/.codexmate` 下维护 CodexMate 自有的 OpenCode 多 provider 存储,只将当前选中的 provider 投影到 OpenCode 原生配置,避免污染或误删用户已有配置。 +- **Provider 健康清理**:探测本地 Codex 与 Claude provider 路由,在同一个弹窗里集中展示失败配置,并批量清理已选坏配置,不影响正常或受保护项。 - **Skills 市场**:本地优先的市场,支持在不同的智能体应用之间共享和导入 Skills。 - **提示词文件编辑器**:统一编辑全局和项目级 `CLAUDE.md` 与 `AGENTS.md`,支持项目路径自动检测。 - **任务编排器**:支持带有依赖跟踪的复杂任务规划与执行。 @@ -76,6 +77,7 @@ | **OpenAI 桥接** | ✅ | 将 Codex Responses API 转换为标准 OpenAI 格式,并在内建转换中附加/规范化 Codex 指纹 | | **Claude Provider 桥接** | ✅ | 通过内建 Claude 兼容代理,让 Claude Code 接入 OpenAI Chat Completions 兼容 provider 与 Ollama | | **OpenCode Provider 存储** | ✅ | 在 `~/.codexmate` 中保留多个 OpenCode provider,只将当前选中的 provider 投影到 OpenCode 原生配置 | +| **Provider 健康检查** | ✅ | 探测本地 Codex/Claude provider 路由,标记失败配置,并安全批量移除已选坏配置 | | **提示词模板** | ✅ | 支持变量的可复用提示词插件 | | **提示词文件编辑器** | ✅ | 编辑全局和项目级 CLAUDE.md / AGENTS.md,支持自动检测与路径切换 | | **MCP 集成** | ✅ | 通过 MCP stdio 暴露本地工具与资源 | diff --git a/cli.js b/cli.js index f29e6cd3..78808257 100644 --- a/cli.js +++ b/cli.js @@ -231,6 +231,15 @@ const PROVIDER_CACHE_FILE_GROUPS = Object.freeze({ 'opencode-provider-current-models.json' ] }); +const PROVIDER_CACHE_PROVIDER_FILES = Object.freeze([ + 'codex-providers.json', + 'claude-providers.json', + 'opencode-providers.json' +]); +const PROVIDER_CACHE_CURRENT_MODEL_FILES = Object.freeze([ + 'codex-provider-current-models.json', + 'opencode-provider-current-models.json' +]); const PROVIDER_CACHE_MAX_FILE_BYTES = 256 * 1024; const CODEXMATE_PREFERENCES_FILE = path.join(CODEXMATE_DIR, 'preferences.json'); const CODEXMATE_OPENCODE_DIR = path.join(CODEXMATE_DIR, 'opencode'); @@ -1083,7 +1092,8 @@ function getApiToolConfigWriteTarget(action) { 'restore-claude-dir', 'claude-local-bridge-toggle', 'claude-local-bridge-set-excluded', - 'claude-local-bridge-sync-providers' + 'claude-local-bridge-sync-providers', + 'delete-provider-cache-record' ]); const opencodeWriteActions = new Set([ 'apply-opencode-config', @@ -2877,6 +2887,99 @@ function normalizeProviderCacheProviderMap(rawProviders) { return providers; } +function removeProviderFromProviderCacheContainer(rawProviders, providerName) { + const targetName = typeof providerName === 'string' ? providerName.trim() : ''; + if (!targetName) return { value: rawProviders, changed: false }; + + const matchesProviderName = (name) => String(name || '').trim() === targetName; + if (Array.isArray(rawProviders)) { + const filtered = rawProviders.filter((item) => { + if (!isPlainObject(item)) return true; + const itemName = pickProviderCacheString(item, ['name', 'id', 'provider']); + return !matchesProviderName(itemName); + }); + return { value: filtered, changed: filtered.length !== rawProviders.length }; + } + if (!isPlainObject(rawProviders)) return { value: rawProviders, changed: false }; + + const next = { ...rawProviders }; + let changed = false; + for (const [name, entry] of Object.entries(rawProviders)) { + const entryName = isPlainObject(entry) + ? pickProviderCacheString(entry, ['name', 'id', 'provider']) + : ''; + if (matchesProviderName(name) || matchesProviderName(entryName)) { + delete next[name]; + changed = true; + } + } + return { value: next, changed }; +} + +function resolveProviderCacheDeleteGroups(groups) { + const requested = Array.isArray(groups) ? groups : (groups ? [groups] : []); + const normalized = requested + .map((item) => String(item || '').trim().toLowerCase()) + .filter((item) => Object.prototype.hasOwnProperty.call(PROVIDER_CACHE_FILE_GROUPS, item)); + return normalized.length ? Array.from(new Set(normalized)) : Object.keys(PROVIDER_CACHE_FILE_GROUPS); +} + +function removeProviderFromProviderCacheRecords(providerName, options = {}) { + const targetName = typeof providerName === 'string' ? providerName.trim() : ''; + const summary = { removed: false, providerFiles: [], currentModelFiles: [] }; + if (!targetName) return summary; + + const groups = resolveProviderCacheDeleteGroups(options.groups || options.group); + const providerFiles = groups + .flatMap((group) => PROVIDER_CACHE_FILE_GROUPS[group] || []) + .filter((fileName) => PROVIDER_CACHE_PROVIDER_FILES.includes(fileName)); + const currentModelFiles = groups + .flatMap((group) => PROVIDER_CACHE_FILE_GROUPS[group] || []) + .filter((fileName) => PROVIDER_CACHE_CURRENT_MODEL_FILES.includes(fileName)); + + for (const fileName of Array.from(new Set(providerFiles))) { + const existing = readProviderCacheJsonObject(fileName); + if (!isPlainObject(existing) || !Object.prototype.hasOwnProperty.call(existing, 'providers')) continue; + const removed = removeProviderFromProviderCacheContainer(existing.providers, targetName); + if (!removed.changed) continue; + writeProviderCacheJsonObject(fileName, { + ...existing, + generatedAt: new Date().toISOString(), + providers: removed.value + }); + summary.removed = true; + summary.providerFiles.push(fileName); + } + + for (const fileName of Array.from(new Set(currentModelFiles))) { + const existing = readProviderCacheJsonObject(fileName); + if (!isPlainObject(existing) || !Object.prototype.hasOwnProperty.call(existing, targetName)) continue; + const next = { ...existing }; + delete next[targetName]; + writeProviderCacheJsonObject(fileName, next); + summary.removed = true; + summary.currentModelFiles.push(fileName); + } + return summary; +} + +function deleteProviderCacheRecord(params = {}) { + const name = typeof params.name === 'string' ? params.name.trim() : ''; + if (!name) return { error: '名称不能为空' }; + const group = typeof params.group === 'string' ? params.group.trim().toLowerCase() : ''; + const groups = resolveProviderCacheDeleteGroups(group || params.groups); + const summary = removeProviderFromProviderCacheRecords(name, { groups }); + return { + success: true, + name, + groups, + removed: summary.removed, + providerFiles: summary.providerFiles, + currentModelFiles: summary.currentModelFiles, + records: readProviderCacheRecords() + }; +} + function readClaudeProviderCacheProvider(name) { const targetName = typeof name === 'string' ? name.trim() : ''; if (!targetName) return null; @@ -2953,10 +3056,11 @@ function buildProviderCacheSyncProviders() { function mergeProviderCacheFile(fileName, nextProviders, buildEntry) { const existing = readProviderCacheJsonObject(fileName); const existingProviders = normalizeProviderCacheProviderMap(existing.providers); - const providers = { ...existingProviders }; + const providers = {}; for (const provider of nextProviders) { const previous = isPlainObject(providers[provider.name]) ? providers[provider.name] : {}; - providers[provider.name] = { ...previous, ...buildEntry(provider) }; + const cachedPrevious = isPlainObject(existingProviders[provider.name]) ? existingProviders[provider.name] : previous; + providers[provider.name] = { ...cachedPrevious, ...buildEntry(provider) }; } const next = { ...existing, @@ -2970,9 +3074,13 @@ function mergeProviderCacheFile(fileName, nextProviders, buildEntry) { function mergeProviderCacheCurrentModelsFile(fileName, nextProviders) { const existing = readProviderCacheJsonObject(fileName); - const next = { ...existing }; + const next = {}; for (const provider of nextProviders) { - if (provider.model) next[provider.name] = provider.model; + if (provider.model) { + next[provider.name] = provider.model; + } else if (typeof existing[provider.name] === 'string' && existing[provider.name].trim()) { + next[provider.name] = existing[provider.name]; + } } const displayPath = writeProviderCacheJsonObject(fileName, next); return { path: displayPath, modelCount: Object.keys(next).length }; @@ -3213,6 +3321,7 @@ function performProviderDeletion(name, options = {}) { writeCurrentModels(currentModels); writeConfig(updatedContent.trimEnd() + lineEnding); + removeProviderFromProviderCacheRecords(name); return result; } @@ -12387,6 +12496,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'sync-provider-cache-records': result = syncProviderCacheRecords(); break; + case 'delete-provider-cache-record': + result = deleteProviderCacheRecord(params || {}); + break; case 'delete-provider': result = deleteProviderFromConfig(params || {}); break; diff --git a/cli/local-bridge.js b/cli/local-bridge.js index ac15799e..8d7ce140 100644 --- a/cli/local-bridge.js +++ b/cli/local-bridge.js @@ -20,6 +20,14 @@ const { isLoopbackAddress } = require('./openai-bridge'); const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils'); +const { + buildBuiltinClaudeChatCompletionsRequest, + buildBuiltinClaudeOllamaChatRequest, + buildAnthropicMessageFromChatCompletion, + buildAnthropicMessageFromOllamaChat, + buildAnthropicModelsPayload, + buildAnthropicStreamEvents +} = require('./claude-proxy'); const BUILTIN_PROXY_PROVIDER_NAME = 'codexmate-proxy'; const BUILTIN_LOCAL_PROVIDER_NAME = 'local'; @@ -28,6 +36,52 @@ const CLAUDE_LOCAL_EXCLUDED_KEY = 'claudeLocalExcluded'; const CIRCUIT_BREAKER_THRESHOLD = 3; const CIRCUIT_BREAKER_COOLDOWN_MS = 5 * 60 * 1000; +function normalizeClaudeLocalTargetApi(value) { + const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (raw === 'chat_completions' || raw === 'chat-completions' || raw === 'chat/completions') return 'chat_completions'; + if (raw === 'ollama') return 'ollama'; + return 'responses'; +} + +function normalizeClaudeLocalSuffix(pathname) { + const suffix = String(pathname || '').replace(/^\/bridge\/claude-local\/?/, '').replace(/^\/+/, ''); + if (!suffix || suffix === 'v1') return ''; + return suffix.replace(/^v1\/?/, ''); +} + +function joinClaudeLocalUpstreamUrl(baseUrl, pathSuffix) { + const suffix = typeof pathSuffix === 'string' ? pathSuffix.replace(/^\/+/, '') : ''; + if (suffix === 'api/tags' || suffix === 'api/chat') { + const normalized = normalizeBaseUrl(baseUrl); + return normalized ? `${normalized}/${suffix}` : ''; + } + return joinApiUrl(baseUrl, suffix); +} + +function buildClaudeLocalAuthHeaders(entry, requestToken, targetApi, expectedToken = '') { + const headers = { 'Content-Type': 'application/json' }; + const trimmedRequestToken = typeof requestToken === 'string' ? requestToken.trim() : ''; + const bridgeToken = typeof expectedToken === 'string' ? expectedToken.trim() : ''; + const upstreamRequestToken = trimmedRequestToken && (!bridgeToken || trimmedRequestToken !== bridgeToken) + ? trimmedRequestToken + : ''; + const token = entry && typeof entry.apiKey === 'string' && entry.apiKey.trim() + ? entry.apiKey.trim() + : upstreamRequestToken; + if (!token) { + if (targetApi === 'responses') headers['anthropic-version'] = '2023-06-01'; + return headers; + } + const bareToken = token.replace(/^Bearer\s+/i, ''); + if (targetApi === 'responses') { + headers['x-api-key'] = bareToken; + headers['anthropic-version'] = '2023-06-01'; + } else { + headers.Authorization = /^Bearer\s+/i.test(token) ? token : `Bearer ${token}`; + } + return headers; +} + function buildUpstreamPool(readConfigFn, openaiBridgeFile, excludedProviders) { let config; try { config = readConfigFn(); } catch (e) { return { error: '读取配置失败' }; } @@ -74,7 +128,13 @@ function buildClaudeUpstreamPool(claudeProvidersFile, excludedProviders) { if (excludedSet.has(name.toLowerCase())) continue; const baseUrl = typeof p.baseUrl === 'string' ? p.baseUrl.trim() : ''; if (!baseUrl || !isValidHttpUrl(normalizeBaseUrl(baseUrl))) continue; - pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', model: typeof p.model === 'string' ? p.model.trim() : '' }); + pool.push({ + name, + baseUrl: normalizeBaseUrl(baseUrl), + apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', + model: typeof p.model === 'string' ? p.model.trim() : '', + targetApi: normalizeClaudeLocalTargetApi(p.targetApi) + }); } if (pool.length === 0) return { error: '请先添加可用的 Claude 上游提供商' }; return { pool }; @@ -263,7 +323,7 @@ function createLocalBridgeHttpHandler(options = {}) { const pool = poolResult.pool; const { entry } = pickUpstream(pool); - const suffix = (parsedUrl.pathname || '').replace(/^\/bridge\/claude-local\/?/, ''); + const suffix = normalizeClaudeLocalSuffix(parsedUrl.pathname || ''); if (!suffix) { if ((req.method || 'GET').toUpperCase() !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' }); @@ -271,11 +331,151 @@ function createLocalBridgeHttpHandler(options = {}) { return; } res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ object: 'codexmate.claude_local_bridge', provider: entry.name, model: entry.model || '', status: 'ok', pool: pool.map(p => p.name) })); + res.end(JSON.stringify({ + object: 'codexmate.claude_local_bridge', + provider: entry.name, + model: entry.model || '', + targetApi: entry.targetApi, + status: 'ok', + pool: pool.map(p => p.name), + targetApis: Object.fromEntries(pool.map(p => [p.name, p.targetApi])) + })); + return; + } + + const targetApi = normalizeClaudeLocalTargetApi(entry.targetApi); + const method = (req.method || 'GET').toUpperCase(); + const isMessagesRequest = suffix === 'messages'; + const isModelsRequest = suffix === 'models'; + + if ((targetApi === 'chat_completions' || targetApi === 'ollama') && !isMessagesRequest && !isModelsRequest) { + res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: 'Claude local bridge transform providers only support /v1/messages and /v1/models' })); + return; + } + + if (targetApi === 'chat_completions' || targetApi === 'ollama') { + if (isModelsRequest) { + if (method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8', Allow: 'GET' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + const modelsUrl = joinClaudeLocalUpstreamUrl(entry.baseUrl.replace(/\/+$/, ''), targetApi === 'ollama' ? 'api/tags' : 'models'); + const upstreamResult = await retryTransientRequest(() => proxyRequestJson(modelsUrl, { + method: 'GET', + body: null, + headers: buildClaudeLocalAuthHeaders(entry, token, targetApi, expectedToken), + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + })); + if (!upstreamResult.ok) { + recordFailure(entry.name); + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` })); + return; + } + const parsedModels = parseJsonOrError(upstreamResult.bodyText || '{}'); + if (upstreamResult.status >= 400) { + recordFailure(entry.name); + res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' })); + return; + } + if (parsedModels.error) { + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream parse failed: ${parsedModels.error}` })); + return; + } + recordSuccess(entry.name); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(buildAnthropicModelsPayload(parsedModels.value))); + return; + } + + if (method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8', Allow: 'POST' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + 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; + } + const parsed = parseJsonOrError(bodyResult.body); + if (parsed.error) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: parsed.error })); + return; + } + const requestPayload = parsed.value && typeof parsed.value === 'object' ? parsed.value : {}; + if (entry.model) requestPayload.model = entry.model; + let upstreamBody; + try { + upstreamBody = targetApi === 'ollama' + ? buildBuiltinClaudeOllamaChatRequest(requestPayload) + : buildBuiltinClaudeChatCompletionsRequest(requestPayload); + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: e && e.message ? e.message : 'Invalid request payload' })); + return; + } + const upstreamUrl = joinClaudeLocalUpstreamUrl(entry.baseUrl.replace(/\/+$/, ''), targetApi === 'ollama' ? 'api/chat' : 'chat/completions'); + const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, { + method: 'POST', + body: upstreamBody, + headers: buildClaudeLocalAuthHeaders(entry, token, targetApi, expectedToken), + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + })); + + 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}` })); + return; + } + if (upstreamResult.status >= 400) { + recordFailure(entry.name); + res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' })); + return; + } + const parsedUpstream = parseJsonOrError(upstreamResult.bodyText || '{}'); + if (parsedUpstream.error) { + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream parse failed: ${parsedUpstream.error}` })); + return; + } + recordSuccess(entry.name); + const anthropicMessage = targetApi === 'ollama' + ? buildAnthropicMessageFromOllamaChat(parsedUpstream.value || {}, requestPayload) + : buildAnthropicMessageFromChatCompletion(parsedUpstream.value || {}, requestPayload); + if (requestPayload.stream === true) { + const events = buildAnthropicStreamEvents(anthropicMessage); + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + for (const event of events) { + if (event && event.event) res.write(`event: ${event.event}\n`); + res.write(`data: ${JSON.stringify(event && event.data ? event.data : {})}\n\n`); + } + res.end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(anthropicMessage)); return; } - // Proxy Anthropic Messages API requests + // Native Anthropic-compatible provider: keep /v1/messages passthrough. const bodyResult = await readRequestBody(req, maxBodySize); if (bodyResult.error) { res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' }); @@ -283,26 +483,26 @@ function createLocalBridgeHttpHandler(options = {}) { return; } - let parsedBody; - try { parsedBody = bodyResult.body ? JSON.parse(bodyResult.body) : {}; } catch (_) { parsedBody = {}; } - // Override model to match the selected upstream provider + const hasRequestBody = typeof bodyResult.body === 'string' && bodyResult.body.trim().length > 0; + let parsedBody = null; + if (hasRequestBody) { + const parsed = parseJsonOrError(bodyResult.body); + if (parsed.error) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: parsed.error })); + return; + } + parsedBody = parsed.value; + } if (entry.model && parsedBody && typeof parsedBody === 'object') { parsedBody.model = entry.model; } - const wantsStream = !!(parsedBody && parsedBody.stream); - const bodyToForward = JSON.stringify(parsedBody); + const wantsStream = !!(parsedBody && typeof parsedBody === 'object' && parsedBody.stream); + const bodyToForward = hasRequestBody ? JSON.stringify(parsedBody) : null; const upstreamUrl = joinApiUrl(entry.baseUrl.replace(/\/+$/, ''), suffix); - const headers = { 'Content-Type': 'application/json' }; - if (entry.apiKey) { - headers['x-api-key'] = entry.apiKey.startsWith('Bearer ') ? entry.apiKey.slice(7) : entry.apiKey; - } - if (token && !entry.apiKey) { - headers['x-api-key'] = token.startsWith('Bearer ') ? token.slice(7) : token; - } - headers['anthropic-version'] = '2023-06-01'; + const headers = buildClaudeLocalAuthHeaders(entry, token, targetApi, expectedToken); if (wantsStream) { - // Streaming proxy: pipe upstream SSE directly to client const upstreamResult = await streamClaudeUpstream(upstreamUrl, { method: req.method || 'POST', body: bodyToForward, @@ -324,10 +524,9 @@ function createLocalBridgeHttpHandler(options = {}) { return; } - // Non-streaming proxy const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, { method: req.method || 'POST', - body: bodyToForward || null, + body: parsedBody || null, headers, maxBytes: maxUpstreamBytes, httpAgent, @@ -432,7 +631,7 @@ function createLocalBridgeHttpHandler(options = {}) { const upstreamResponsesUrl = joinApiUrl(upstreamBase, 'responses'); const upstreamResponsesResult = await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, { method: 'POST', - body: bodyResult.body, + body: responsesRequest, headers: { ...(authHeader ? { Authorization: authHeader } : {}) }, maxBytes: maxUpstreamBytes, httpAgent, @@ -484,7 +683,7 @@ function createLocalBridgeHttpHandler(options = {}) { const chatUrl = joinApiUrl(upstreamBase, 'chat/completions'); const chatResult = await retryTransientRequest(() => proxyRequestJson(chatUrl, { method: 'POST', - body: JSON.stringify(converted.chat), + body: converted.chat, headers: { ...(authHeader ? { Authorization: authHeader } : {}), 'Content-Type': 'application/json' }, maxBytes: maxUpstreamBytes, httpAgent, diff --git a/package-lock.json b/package-lock.json index 00705df7..7edabef1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codexmate", - "version": "0.0.55", + "version": "0.0.56", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexmate", - "version": "0.0.55", + "version": "0.0.56", "license": "Apache-2.0", "dependencies": { "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index 939bde63..6957418d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexmate", - "version": "0.0.55", + "version": "0.0.56", "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具", "main": "cli.js", "bin": { diff --git a/tests/e2e/test-config.js b/tests/e2e/test-config.js index 418d4c9f..f6e7a203 100644 --- a/tests/e2e/test-config.js +++ b/tests/e2e/test-config.js @@ -1501,9 +1501,71 @@ preferred_auth_method = "shadow-key" const deleteProviderEmpty = await api('delete-provider', { name: '' }); assert(deleteProviderEmpty.error, 'delete-provider should fail for empty name'); + const syncedProviderCacheBeforeDelete = await api('sync-provider-cache-records'); + assert(syncedProviderCacheBeforeDelete.success === true, 'provider cache sync before delete should succeed'); + const cacheContainsProvider = (records, providerName) => Array.isArray(records && records.groups) + && records.groups.some((group) => Array.isArray(group && group.files) + && group.files.some((file) => Array.isArray(file && file.providers) + && file.providers.some((provider) => provider && provider.name === providerName))); + assert(cacheContainsProvider(syncedProviderCacheBeforeDelete.records, 'e2e-api'), 'provider cache should contain e2e-api before delete'); + const deleteProviderResult = await api('delete-provider', { name: 'e2e-api' }); assert(deleteProviderResult.success === true, 'delete-provider failed'); + for (let i = 0; i < 3; i += 1) { + const refreshedAfterDelete = await api('get-provider-cache-records'); + assert(!cacheContainsProvider(refreshedAfterDelete.records, 'e2e-api'), 'deleted provider should not reappear in provider cache refresh'); + } + const syncedProviderCacheAfterDelete = await api('sync-provider-cache-records'); + assert(syncedProviderCacheAfterDelete.success === true, 'provider cache sync after delete should succeed'); + assert(!cacheContainsProvider(syncedProviderCacheAfterDelete.records, 'e2e-api'), 'provider cache sync should prune deleted provider instead of resurrecting it'); + + const codexmateDir = path.join(tmpHome, '.codexmate'); + fs.mkdirSync(codexmateDir, { recursive: true }); + const claudeProviderCachePath = path.join(codexmateDir, 'claude-providers.json'); + fs.writeFileSync(claudeProviderCachePath, JSON.stringify({ + version: 1, + providers: { + 'claude-cache-zombie': { + name: 'claude-cache-zombie', + baseUrl: 'https://claude-cache-zombie.example.test', + apiKey: 'sk-cache-zombie', + model: 'claude-cache-zombie-model', + targetApi: 'responses' + }, + 'claude-cache-survivor': { + name: 'claude-cache-survivor', + baseUrl: 'https://claude-cache-survivor.example.test', + apiKey: 'sk-cache-survivor', + model: 'claude-cache-survivor-model', + targetApi: 'responses' + } + } + }, null, 2)); + const claudeCacheConfigsBeforeDelete = await api('get-claude-provider-cache-configs'); + assert( + claudeCacheConfigsBeforeDelete.providers.some(provider => provider.name === 'claude-cache-zombie'), + 'Claude provider-cache config should exist before cache delete' + ); + const deniedClaudeCacheDelete = await api('delete-provider-cache-record', { name: 'claude-cache-zombie', group: 'claude' }); + assert(deniedClaudeCacheDelete.errorCode === 'tool-config-write-disabled', 'Claude provider-cache delete should require claude write permission'); + const enableClaudeWrites = await api('set-tool-config-permission', { target: 'claude', allowWrite: true }); + assert(enableClaudeWrites.success === true, 'set-tool-config-permission(claude) should succeed'); + const deleteClaudeCacheProvider = await api('delete-provider-cache-record', { name: 'claude-cache-zombie', group: 'claude' }); + assert(deleteClaudeCacheProvider.success === true, 'delete-provider-cache-record should succeed for Claude cache'); + assert(deleteClaudeCacheProvider.removed === true, 'delete-provider-cache-record should report removed provider'); + const claudeCacheConfigsAfterDelete = await api('get-claude-provider-cache-configs'); + assert( + !claudeCacheConfigsAfterDelete.providers.some(provider => provider.name === 'claude-cache-zombie'), + 'deleted Claude provider-cache config should not rehydrate after refresh' + ); + assert( + claudeCacheConfigsAfterDelete.providers.some(provider => provider.name === 'claude-cache-survivor'), + 'delete-provider-cache-record should keep unrelated Claude cache configs' + ); + const disableClaudeWritesAfterCacheDelete = await api('set-tool-config-permission', { target: 'claude', allowWrite: false }); + assert(disableClaudeWritesAfterCacheDelete.success === true, 'test-config should restore claude write permission after provider-cache delete e2e'); + const deleteLocalProviderResult = await api('delete-provider', { name: 'local' }); assert(deleteLocalProviderResult.error, 'delete-provider should reject reserved local provider'); diff --git a/tests/e2e/test-web-ui-session-browser.js b/tests/e2e/test-web-ui-session-browser.js index 306edb46..1382308b 100644 --- a/tests/e2e/test-web-ui-session-browser.js +++ b/tests/e2e/test-web-ui-session-browser.js @@ -334,7 +334,7 @@ module.exports = async function testWebUiSessionBrowser(ctx) { assert(vm.activeSessionMessages.length <= 2, 'session browser should not duplicate the initial huge-line preview messages'); assert(vm.activeSessionMessages.every((message) => typeof message.text === 'string' && message.text.length <= 4000), 'session browser should cap huge-line preview text on initial tab entry'); assert(vm.activeSessionDetailClipped === false, 'session browser should fully recover small huge-line sessions on initial tab entry'); - assert(vm.activeSessionVisibleMessages.length === vm.activeSessionMessages.length, 'session browser should render all truncated huge-line preview messages on initial tab entry'); + assert(vm.activeSessionVisibleMessages.length === Math.min(vm.activeSessionMessages.length, vm.sessionPreviewInitialBatchSize), 'session browser should batch initial huge-line preview rendering'); const sampleListSessionId = hydrateZeroSessionId; const sampleListSession = vm.sessionsList.find((item) => item && item.sessionId === sampleListSessionId); @@ -372,7 +372,8 @@ module.exports = async function testWebUiSessionBrowser(ctx) { assert(vm.activeSessionMessages.length >= vm.sessionDetailInitialMessageLimit, 'session browser preview should hydrate at least the initial detail window for huge sessions'); assert(vm.activeSessionDetailClipped === true, 'session browser preview should stay clipped for huge sessions'); - assert(vm.activeSessionVisibleMessages.length === vm.activeSessionMessages.length, 'session browser should render all loaded messages immediately without batching'); + assert(vm.activeSessionVisibleMessages.length <= vm.sessionPreviewInitialBatchSize, 'session browser should batch large detail rendering to avoid DOM jank'); + assert(vm.activeSessionVisibleMessages.length < vm.activeSessionMessages.length, 'session browser should not render the whole large detail window at once'); assert(vm.sessionDetailMessageLimit >= vm.sessionDetailInitialMessageLimit, 'session browser detail limit should be at least the initial window'); assert(vm.activeSessionMessages.length < hotSessionMessageCount, 'session browser should not pull the whole huge session into memory'); @@ -394,5 +395,5 @@ module.exports = async function testWebUiSessionBrowser(ctx) { assert(vm.activeSessionMessages.length > 0, 'session browser should recover huge-line previews through the real UI->API data flow'); assert(vm.activeSessionMessages.length <= 3, 'session browser should not duplicate huge-line fallback preview messages'); assert(vm.activeSessionDetailClipped === false, 'session browser should mark the huge-line fallback preview as fully recovered'); - assert(vm.activeSessionVisibleMessages.length === vm.activeSessionMessages.length, 'session browser should render all recovered huge-line messages without overfetching'); + assert(vm.activeSessionVisibleMessages.length === Math.min(vm.activeSessionMessages.length, vm.sessionPreviewInitialBatchSize), 'session browser should keep recovered huge-line rendering batched'); }; diff --git a/tests/unit/agents-modal-guards.test.mjs b/tests/unit/agents-modal-guards.test.mjs index 7ba4c2fd..2e5dcf2c 100644 --- a/tests/unit/agents-modal-guards.test.mjs +++ b/tests/unit/agents-modal-guards.test.mjs @@ -11,9 +11,18 @@ const { createAgentsMethods } = await import( const { createCodexConfigMethods } = await import( pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.codex-config.mjs')) ); +const { createClaudeConfigMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.claude-config.mjs')) +); const { createI18nMethods } = await import( pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs')) ); +const { createRuntimeMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.runtime.mjs')) +); +const { buildSpeedTestIssue } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'logic.runtime.mjs')) +); test('closeConfigTemplateModal ignores user close attempts while template apply is busy', () => { const methods = createCodexConfigMethods({ @@ -231,61 +240,103 @@ test('runHealthCheck treats backend error payloads as failures', async () => { }]); }); -test('runHealthCheck skips Claude speed tests when the primary health check already failed', async () => { +test('runHealthCheck checks only the selected Claude config', async () => { const methods = createCodexConfigMethods({ - api: async () => ({ error: 'health failed' }), + api: async (action) => { + throw new Error(`Claude batch health check should not call backend action: ${action}`); + }, getProviderConfigModeMeta() { return null; } }); - let claudeSpeedTestCalls = 0; + const speedTestCalls = []; + const runtimeMethods = createRuntimeMethods({ + api: async (action, payload) => { + assert.strictEqual(action, 'speed-test'); + speedTestCalls.push(payload); + if (payload.targetApi === 'chat_completions') return { ok: false, error: 'timeout' }; + return { ok: true, durationMs: payload.targetApi === 'responses' ? 11 : 22, status: 200 }; + } + }); const context = { ...createI18nMethods(), ...methods, + ...runtimeMethods, lang: 'zh', - providersList: ['alpha'], + providersList: ['codex-provider-should-not-run'], speedResults: {}, speedLoading: {}, + claudeSpeedResults: {}, + claudeSpeedLoading: {}, healthCheckLoading: false, healthCheckResult: { ok: true }, + healthCheckBatchTotal: 99, + healthCheckBatchDone: 99, + healthCheckBatchFailed: 99, configMode: 'claude', + currentClaudeConfig: 'chat', claudeConfigs: { - primary: { - baseUrl: 'https://example.com', - apiKey: 'secret' + anthropic: { + baseUrl: 'https://anthropic.example.com/v1', + apiKey: 'sk-anthropic', + model: 'claude-sonnet-4-6', + targetApi: 'responses' + }, + chat: { + baseUrl: 'https://openai.example.com/v1', + apiKey: 'sk-chat', + model: 'gpt-4.1', + targetApi: 'chat_completions' + }, + ollama: { + baseUrl: 'http://127.0.0.1:11434', + apiKey: '', + model: 'llama3.1:8b', + targetApi: 'ollama' } }, shownMessages: [], showMessage(message, type) { this.shownMessages.push({ message, type }); }, - async runClaudeSpeedTest() { - claudeSpeedTestCalls += 1; - return { ok: false, error: 'timeout' }; - } + buildSpeedTestIssue }; await methods.runHealthCheck.call(context); + const callsByTarget = Object.fromEntries(speedTestCalls.map((payload) => [payload.targetApi, payload])); assert.strictEqual(context.healthCheckLoading, false); - assert.strictEqual(claudeSpeedTestCalls, 1); + assert.strictEqual(context.healthCheckBatchTotal, 1); + assert.strictEqual(context.healthCheckBatchDone, 1); + assert.strictEqual(context.healthCheckBatchFailed, 1); + assert.deepStrictEqual(speedTestCalls.map((payload) => payload.targetApi).sort(), ['chat_completions']); + assert.strictEqual(callsByTarget.chat_completions.apiKey, 'sk-chat'); + assert.strictEqual(context.claudeSpeedResults.ollama, undefined); assert.strictEqual(context.healthCheckResult.ok, false); + assert.deepStrictEqual(context.healthCheckResult.remote.speedTests, { + chat: { ok: false, error: 'timeout' } + }); + assert.deepStrictEqual(context.healthCheckResult.issues, [{ + code: 'remote-speedtest-timeout', + message: '提供商 chat 远程测速超时', + suggestion: '检查网络或 base_url 是否可达' + }]); + assert.deepStrictEqual(context.shownMessages, []); }); -test('runHealthCheck preserves backend remote health result while appending speed test summaries', async () => { +test('runHealthCheck checks only the current Codex route and ignores unselected provider probe failures', async () => { + const apiCalls = []; + let configResolve; + let providersResolve; + const configPromise = new Promise((resolve) => { configResolve = resolve; }); + const providersPromise = new Promise((resolve) => { providersResolve = resolve; }); 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' - } - }), + api: async (action, payload) => { + apiCalls.push({ action, payload }); + if (action === 'config-health-check') return configPromise; + if (action === 'providers-health') return providersPromise; + throw new Error(`unexpected action: ${action}`); + }, getProviderConfigModeMeta() { return null; } @@ -294,33 +345,423 @@ test('runHealthCheck preserves backend remote health result while appending spee ...createI18nMethods(), ...methods, lang: 'zh', - providersList: ['alpha', 'beta'], + providersList: [{ name: 'local' }, { name: 'bad' }, { name: 'system', nonDeletable: true }], + currentProvider: 'local', speedResults: {}, speedLoading: {}, healthCheckLoading: false, healthCheckResult: null, + healthCheckFailedProviderSelections: {}, + showHealthCheckModal: false, + healthCheckBatchTotal: 99, + healthCheckBatchDone: 99, + healthCheckBatchFailed: 99, configMode: 'codex', shownMessages: [], showMessage(message, type) { this.shownMessages.push({ message, type }); }, - async runSpeedTest(name) { - return { ok: true, durationMs: name === 'alpha' ? 10 : 20, status: 200 }; + shouldShowProviderDelete(provider) { + return !(provider && provider.nonDeletable); + }, + isToolConfigWriteAllowed() { + return true; + }, + async runSpeedTest() { + throw new Error('Codex settings health check must not run provider-card speed tests'); }, buildSpeedTestIssue() { - return null; + throw new Error('speed test issues should not be built'); + }, + runProvidersHealthCheck() { + throw new Error('runProvidersHealthCheck UI helper should not be used here'); } }; - await methods.runHealthCheck.call(context); + const runPromise = methods.runHealthCheck.call(context); + await Promise.resolve(); + assert.deepStrictEqual(apiCalls, [ + { action: 'config-health-check', payload: { remote: true } }, + { action: 'providers-health', payload: { remote: false } } + ]); + + providersResolve({ + ok: false, + currentProvider: 'local', + summary: { total: 3, green: 1, yellow: 1, red: 1 }, + providers: [ + { provider: 'local', status: 'green', issues: [], remote: { ok: true, statusCode: 200, message: 'ok' } }, + { provider: 'bad', status: 'red', issues: [{ code: 'remote-model-probe-http-error', message: 'bad HTTP 502' }], remote: { ok: false, statusCode: 502, message: 'bad' } }, + { provider: 'system', status: 'yellow', issues: [{ code: 'api-key-missing', message: 'system key missing' }], remote: null } + ] + }); + configResolve({ + ok: false, + issues: [{ code: 'remote-model-probe-http-error', message: 'local HTTP 502' }], + summary: { currentProvider: 'local', currentModel: 'e2e-model' }, + remote: { type: 'remote-health-check', provider: 'local', ok: false, statusCode: 502, message: 'local HTTP 502' } + }); + await runPromise; assert.strictEqual(context.healthCheckLoading, false); + assert.strictEqual(context.showHealthCheckModal, true); + assert.deepStrictEqual(context.providersHealthResult.providers.map((provider) => provider.provider), ['local', 'bad', 'system']); + assert.strictEqual(context.healthCheckBatchTotal, 1); + assert.strictEqual(context.healthCheckBatchDone, 1); + assert.strictEqual(context.healthCheckBatchFailed, 1); + assert.strictEqual(context.healthCheckResult.ok, false); assert.strictEqual(context.healthCheckResult.remote.type, 'remote-health-check'); - assert.strictEqual(context.healthCheckResult.remote.statusCode, 200); + assert.deepStrictEqual(context.healthCheckResult.issues.map((issue) => issue.provider), [undefined]); + assert.deepStrictEqual(context.getHealthCheckFailedProviderItems().map((item) => ({ + name: item.name, + status: item.status, + deletable: item.deletable, + detail: item.detail + })), [ + { name: 'local', status: 'red', deletable: true, detail: '502 · local HTTP 502' } + ]); + assert.deepStrictEqual(context.getSelectableHealthCheckFailedProviderItems().map((item) => item.name), ['local']); + assert.strictEqual(context.areAllHealthCheckFailedProvidersSelected(), false); + context.setAllHealthCheckFailedProviderSelections(true); + assert.deepStrictEqual(context.healthCheckFailedProviderSelections, { 'codex:local': true }); + assert.strictEqual(context.hasHealthCheckFailedProviderSelection(), true); + assert.strictEqual(context.areAllHealthCheckFailedProvidersSelected(), true); + context.setAllHealthCheckFailedProviderSelections(false); + assert.deepStrictEqual( + context.getSelectableHealthCheckFailedProviderItems().filter((item) => item.selected).map((item) => item.name), + [] + ); + assert.strictEqual(context.hasHealthCheckFailedProviderSelection(), false); + assert.deepStrictEqual(context.shownMessages, [{ message: '检查失败', type: 'error' }]); +}); + +test('deleteSelectedHealthCheckFailedProviders deletes only selected deletable failed providers', async () => { + const deleted = []; + const methods = createCodexConfigMethods({ + api: async (action, payload) => { + if (action === 'delete-provider') { + deleted.push(payload.name); + return { success: true }; + } + return { ok: true, issues: [], summary: {}, remote: null }; + }, + getProviderConfigModeMeta() { + return null; + } + }); + const context = { + ...createI18nMethods(), + ...methods, + lang: 'zh', + configMode: 'codex', + providersList: [{ name: 'bad' }, { name: 'system', nonDeletable: true }, { name: 'ok' }], + healthCheckFailedProviderSelections: {}, + healthCheckFailedProviderDeleting: false, + showHealthCheckModal: true, + healthCheckBatchTotal: 3, + healthCheckBatchDone: 3, + healthCheckBatchFailed: 2, + healthCheckResult: { + ok: false, + issues: [ + { provider: 'bad', message: 'bad HTTP 502' }, + { provider: 'system', message: 'system key missing' } + ], + remote: { + type: 'providers-health', + currentProvider: 'ok', + summary: { total: 3, green: 1, yellow: 1, red: 1 }, + providers: [ + { provider: 'ok', status: 'green', issues: [] }, + { provider: 'bad', status: 'red', issues: [{ message: 'bad HTTP 502' }] }, + { provider: 'system', status: 'yellow', issues: [{ message: 'system key missing' }] } + ] + } + }, + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + shouldShowProviderDelete(provider) { + return !(provider && provider.nonDeletable); + }, + isToolConfigWriteAllowed() { + return true; + } + }; + + context.setAllHealthCheckFailedProviderSelections(true); + assert.deepStrictEqual(context.healthCheckFailedProviderSelections, { 'codex:bad': true }); + assert.strictEqual(context.areAllHealthCheckFailedProvidersSelected(), true); + + await methods.deleteSelectedHealthCheckFailedProviders.call(context); + + assert.deepStrictEqual(deleted, ['bad']); + assert.deepStrictEqual(context.healthCheckFailedProviderSelections, {}); + assert.deepStrictEqual(context.healthCheckResult.remote.providers.map((provider) => provider.provider), ['ok', 'system']); + assert.deepStrictEqual(context.healthCheckResult.issues.map((issue) => issue.provider), ['system']); + assert.strictEqual(context.healthCheckBatchTotal, 2); + assert.strictEqual(context.healthCheckBatchFailed, 1); + assert.strictEqual(context.showHealthCheckModal, false); + assert.deepStrictEqual(context.shownMessages, [{ message: '已删除 1 个失败提供商', type: 'success' }]); +}); + +test('deleteSelectedHealthCheckFailedProviders bulk-deletes Claude configs and provider-cache refs without per-item confirmation', async () => { + const cacheDeletes = []; + const methods = createCodexConfigMethods({ + api: async (action, payload) => { + if (action === 'delete-provider-cache-record') { + cacheDeletes.push(payload); + return { success: true, removed: true }; + } + throw new Error(`Unexpected backend action for Claude bulk cleanup: ${action}`); + }, + getProviderConfigModeMeta() { + return null; + } + }); + const context = { + ...createI18nMethods(), + ...methods, + lang: 'zh', + configMode: 'claude', + claudeConfigs: { + bad: { name: 'bad', providerCacheRef: 'bad', source: 'provider-cache' }, + worse: { name: 'worse', providerCacheRef: 'worse-cache', source: 'provider-cache' }, + ok: { name: 'ok', providerCacheRef: 'ok-cache', source: 'provider-cache' } + }, + currentClaudeConfig: 'bad', + healthCheckFailedProviderSelections: {}, + healthCheckFailedProviderDeleting: false, + showHealthCheckModal: true, + healthCheckBatchTotal: 3, + healthCheckBatchDone: 3, + healthCheckBatchFailed: 2, + healthCheckResult: { + ok: false, + issues: [ + { provider: 'bad', message: 'bad failed' }, + { providerName: 'worse', message: 'worse failed' } + ], + remote: { + type: 'speed-test', + speedTests: { + bad: { ok: false, error: 'bad failed' }, + worse: { ok: false, error: 'worse failed' }, + ok: { ok: true, durationMs: 12, status: 200 } + } + } + }, + saved: 0, + refreshed: 0, + remembered: [], + applied: [], + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + isToolConfigWriteAllowed() { + return true; + }, + saveClaudeConfigs() { + this.saved += 1; + }, + refreshClaudeModelContext() { + this.refreshed += 1; + }, + rememberDeletedClaudeSettingsImport(config) { + this.remembered.push(config && config.providerCacheRef); + }, + async applyCurrentClaudeConfigSilently() { + this.applied.push(this.currentClaudeConfig); + return true; + }, + async requestConfirmDialog() { + throw new Error('bulk failed-provider cleanup must not request per-item confirmation'); + }, + async deleteClaudeConfig() { + throw new Error('bulk failed-provider cleanup must not call deleteClaudeConfig'); + } + }; + + context.setAllHealthCheckFailedProviderSelections(true); + assert.deepStrictEqual(context.healthCheckFailedProviderSelections, { 'claude:bad': true, 'claude:worse': true }); + + await methods.deleteSelectedHealthCheckFailedProviders.call(context); + + assert.deepStrictEqual(cacheDeletes, [ + { name: 'bad', group: 'claude' }, + { name: 'worse-cache', group: 'claude' } + ]); + assert.deepStrictEqual(Object.keys(context.claudeConfigs), ['ok']); + assert.strictEqual(context.currentClaudeConfig, 'ok'); + assert.strictEqual(context.saved, 1); + assert.strictEqual(context.refreshed, 0); + assert.deepStrictEqual(context.remembered, ['bad', 'worse-cache']); + assert.deepStrictEqual(context.applied, ['ok']); + assert.deepStrictEqual(context.healthCheckFailedProviderSelections, {}); + assert.deepStrictEqual(context.healthCheckResult.issues, []); assert.deepStrictEqual(context.healthCheckResult.remote.speedTests, { - alpha: { ok: true, durationMs: 10, status: 200 }, - beta: { ok: true, durationMs: 20, status: 200 } + ok: { ok: true, durationMs: 12, status: 200 } }); + assert.strictEqual(context.healthCheckResult.ok, true); + assert.strictEqual(context.healthCheckBatchTotal, 1); + assert.strictEqual(context.healthCheckBatchDone, 1); + assert.strictEqual(context.healthCheckBatchFailed, 0); + assert.strictEqual(context.showHealthCheckModal, false); + assert.deepStrictEqual(context.shownMessages, [{ message: '已删除 2 个失败提供商', type: 'success' }]); +}); + +test('deleteClaudeConfig prunes provider-cache source before local removal', async () => { + const cacheDeletes = []; + const methods = createClaudeConfigMethods({ + api: async (action, payload) => { + if (action === 'delete-provider-cache-record') { + cacheDeletes.push(payload); + return { success: true, removed: true }; + } + return { success: true }; + } + }); + const context = { + ...createI18nMethods(), + ...methods, + lang: 'zh', + claudeConfigs: { + bad: { name: 'bad', providerCacheRef: 'bad-cache', source: 'provider-cache' }, + ok: { name: 'ok' } + }, + currentClaudeConfig: 'bad', + saved: 0, + refreshed: 0, + remembered: [], + applied: [], + synced: 0, + shownMessages: [], + async requestConfirmDialog() { + return true; + }, + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + saveClaudeConfigs() { + this.saved += 1; + }, + refreshClaudeModelContext() { + this.refreshed += 1; + }, + rememberDeletedClaudeSettingsImport(config) { + this.remembered.push(config && config.providerCacheRef); + }, + async applyCurrentClaudeConfigSilently() { + this.applied.push(this.currentClaudeConfig); + return true; + }, + syncClaudeBridgeProviders() { + this.synced += 1; + } + }; + + await methods.deleteClaudeConfig.call(context, 'bad'); + + assert.deepStrictEqual(cacheDeletes, [{ name: 'bad-cache', group: 'claude' }]); + assert.deepStrictEqual(Object.keys(context.claudeConfigs), ['ok']); + assert.strictEqual(context.currentClaudeConfig, 'ok'); + assert.strictEqual(context.saved, 1); + assert.strictEqual(context.refreshed, 0); + assert.deepStrictEqual(context.remembered, ['bad-cache']); + assert.deepStrictEqual(context.applied, ['ok']); + assert.deepStrictEqual(context.shownMessages, [{ message: '操作成功', type: 'success' }]); +}); + +test('deleteClaudeConfig reports provider-cache delete transport failures without local removal', async () => { + const methods = createClaudeConfigMethods({ + api: async (action) => { + if (action === 'delete-provider-cache-record') { + throw new Error('network down'); + } + return { success: true }; + } + }); + const context = { + ...createI18nMethods(), + ...methods, + lang: 'zh', + claudeConfigs: { + bad: { name: 'bad', providerCacheRef: 'bad-cache', source: 'provider-cache' }, + ok: { name: 'ok' } + }, + currentClaudeConfig: 'bad', + saved: 0, + refreshed: 0, + shownMessages: [], + async requestConfirmDialog() { + return true; + }, + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + saveClaudeConfigs() { + this.saved += 1; + }, + refreshClaudeModelContext() { + this.refreshed += 1; + } + }; + + await methods.deleteClaudeConfig.call(context, 'bad'); + + assert.deepStrictEqual(Object.keys(context.claudeConfigs), ['bad', 'ok']); + assert.strictEqual(context.currentClaudeConfig, 'bad'); + assert.strictEqual(context.saved, 0); + assert.strictEqual(context.refreshed, 0); + assert.deepStrictEqual(context.shownMessages, [{ message: 'network down', type: 'error' }]); +}); + +test('deleteSelectedHealthCheckFailedProviders requires an explicit selected provider', async () => { + const methods = createCodexConfigMethods({ + api: async () => ({ ok: true, issues: [], summary: {}, remote: null }), + getProviderConfigModeMeta() { + return null; + } + }); + const context = { + ...createI18nMethods(), + ...methods, + lang: 'zh', + configMode: 'codex', + providersList: [{ name: 'bad' }], + healthCheckFailedProviderSelections: {}, + showHealthCheckModal: true, + healthCheckResult: { + ok: false, + issues: [{ provider: 'bad', message: 'bad HTTP 502' }], + remote: { + type: 'providers-health', + providers: [ + { provider: 'bad', status: 'red', issues: [{ message: 'bad HTTP 502' }] } + ] + } + }, + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + shouldShowProviderDelete() { + return true; + }, + isToolConfigWriteAllowed() { + return true; + }, + async deleteProvider() { + throw new Error('delete should require explicit selection'); + } + }; + + await methods.deleteSelectedHealthCheckFailedProviders.call(context); + + assert.strictEqual(context.showHealthCheckModal, true); + assert.deepStrictEqual(context.shownMessages, [{ message: '请先选择至少一个失败提供商', type: 'info' }]); }); test('applyCodexConfigDirect keeps the successful apply result when only the refresh fails', async () => { diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs index b51d425c..4ca4dc7d 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -15,6 +15,9 @@ const { createCodexConfigMethods } = await import( const { createClaudeConfigMethods } = await import( pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.claude-config.mjs')) ); +const { createStartupClaudeMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.startup-claude.mjs')) +); const { isLikelyBuiltinClaudeProxySettingsEnv, matchBuiltinClaudeProxyConfigFromSettings @@ -619,6 +622,69 @@ test('refreshClaudeSelectionFromSettings keeps builtin Claude proxy selection wi assert.deepStrictEqual(messages, []); }); +test('refreshClaudeSelectionFromSettings suppresses re-import of deleted Claude settings fingerprints', async () => { + const previousLocalStorage = globalThis.localStorage; + const stored = new Map([[ 'deletedClaudeSettingsImports', JSON.stringify([{ + baseUrl: 'https://zombie.example.com/anthropic', + model: 'claude-opus-4-6', + providerCacheRef: 'claude-cache-zombie', + deletedAt: Date.now() + }]) ]]); + globalThis.localStorage = { + getItem(key) { return stored.has(key) ? stored.get(key) : null; }, + setItem(key, value) { stored.set(key, String(value)); }, + removeItem(key) { stored.delete(key); } + }; + try { + const methods = createStartupClaudeMethods({ + api: async (action) => { + if (action === 'get-claude-settings') { + return { + exists: true, + env: { + ANTHROPIC_API_KEY: 'sk-zombie', + ANTHROPIC_BASE_URL: 'https://zombie.example.com/anthropic/', + ANTHROPIC_MODEL: 'claude-opus-4-6' + } + }; + } + return { success: true }; + } + }); + let ensureCount = 0; + const context = { + ...methods, + claudeConfigs: { + survivor: { + apiKey: 'sk-survivor', + baseUrl: 'https://survivor.example.com/anthropic', + model: 'claude-sonnet-4-6', + hasKey: true + } + }, + currentClaudeConfig: 'survivor', + currentClaudeModel: '', + matchClaudeConfigFromSettings: () => '', + matchBuiltinClaudeProxyConfigFromSettings: () => '', + ensureClaudeConfigFromSettings() { + ensureCount += 1; + throw new Error('deleted Claude settings must not be imported again'); + }, + refreshClaudeModelContext() {}, + resetClaudeModelsState() {}, + showMessage() {} + }; + + await methods.refreshClaudeSelectionFromSettings.call(context, { silent: true }); + + assert.strictEqual(ensureCount, 0); + assert.deepStrictEqual(Object.keys(context.claudeConfigs), ['survivor']); + assert.strictEqual(context.currentClaudeConfig, 'survivor'); + } finally { + globalThis.localStorage = previousLocalStorage; + } +}); + test('builtin Claude proxy settings detection requires loopback URL and generated proxy token shape', () => { assert.strictEqual(isLikelyBuiltinClaudeProxySettingsEnv({ ANTHROPIC_API_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef', @@ -1509,6 +1575,99 @@ test('hydrateClaudeConfigsFromProviderCache restores Claude providers without st } }); +test('hydrateClaudeConfigsFromProviderCache prunes stale cache-backed Claude configs', async () => { + const previousLocalStorage = globalThis.localStorage; + const stored = new Map([['currentClaudeConfig', 'cache-zombie']]); + globalThis.localStorage = { + getItem(key) { return stored.has(key) ? stored.get(key) : null; }, + setItem(key, value) { stored.set(key, String(value)); }, + removeItem(key) { stored.delete(key); } + }; + try { + const methods = createClaudeConfigMethods({ + api: async (action) => { + if (action === 'get-claude-provider-cache-configs') { + return { + providers: [{ + name: 'cache-survivor', + baseUrl: 'https://survivor.example.com/anthropic', + model: 'claude-sonnet-4-6', + targetApi: 'responses', + hasKey: true, + providerCacheRef: 'cache-survivor', + source: 'provider-cache' + }] + }; + } + return { success: true }; + } + }); + const context = { + ...methods, + claudeConfigs: { + 'cache-zombie': { + apiKey: '', + baseUrl: 'https://zombie.example.com/anthropic', + model: 'claude-opus-4-6', + targetApi: 'responses', + hasKey: true, + providerCacheRef: 'cache-zombie', + source: 'provider-cache' + }, + 'manual-provider': { + apiKey: 'sk-manual', + baseUrl: 'https://manual.example.com/anthropic', + model: 'claude-haiku-4-5', + targetApi: 'responses', + hasKey: true + } + }, + currentClaudeConfig: 'cache-zombie', + showMessage() { throw new Error('should stay silent'); }, + syncClaudeBridgeProviders() {}, + refreshClaudeModelContext() {}, + t(key) { return key; } + }; + + const ok = await context.hydrateClaudeConfigsFromProviderCache({ silent: true }); + + assert.strictEqual(ok, true); + assert.strictEqual(context.claudeConfigs['cache-zombie'], undefined); + assert(context.claudeConfigs['cache-survivor'], 'live provider-cache config should remain hydrated'); + assert(context.claudeConfigs['manual-provider'], 'manual Claude config should not be pruned'); + assert.notStrictEqual(context.currentClaudeConfig, 'cache-zombie'); + assert.doesNotMatch(stored.get('claudeConfigs') || '', /cache-zombie/); + assert.match(stored.get('claudeConfigs') || '', /cache-survivor/); + } finally { + globalThis.localStorage = previousLocalStorage; + } +}); + +test('selectClaudeFallbackConfigName prefers applyable Claude configs over placeholder defaults', () => { + const methods = createClaudeConfigMethods({ api: async () => ({ success: true }) }); + const context = { + claudeConfigs: { + '智谱GLM': { + apiKey: '', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + model: 'glm-4.7', + targetApi: 'responses', + hasKey: false + }, + survivor: { + apiKey: '', + baseUrl: 'https://survivor.example.com/anthropic', + model: 'claude-sonnet-4-6', + providerCacheRef: 'survivor-cache', + source: 'provider-cache', + hasKey: true + } + } + }; + + assert.strictEqual(methods.selectClaudeFallbackConfigName.call(context, ['deleted']), 'survivor'); +}); + test('applyClaudeConfig accepts provider-cache backed Claude providers without browser api key', async () => { const previousLocalStorage = globalThis.localStorage; const stored = new Map(); diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index c236265a..6055425b 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -126,6 +126,10 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(styles, /\.task-action-row-right\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-wrap:\s*wrap;/); assert.match(styles, /\.task-runtime-item-actions\s*\{[\s\S]*flex-direction:\s*row;[\s\S]*align-items:\s*center;/); } + for (const styles of [bundledStyles]) { + assert.match(styles, /\.health-failed-provider-main input\s*\{[\s\S]*flex:\s*0 0 auto;[\s\S]*width:\s*13px;[\s\S]*height:\s*13px;[\s\S]*accent-color:\s*var\(--color-brand-dark\);/); + assert.match(styles, /\.health-failed-provider-main > span\s*\{[\s\S]*min-width:\s*0;/); + } const sideGhostTab = sideRail.match(/