diff --git a/cli.js b/cli.js index c455edf8..e5d99868 100644 --- a/cli.js +++ b/cli.js @@ -107,6 +107,9 @@ const { readOpenaiBridgeSettings, resolveOpenaiBridgeUpstream } = require('./cli/openai-bridge'); +const { + createLocalBridgeHttpHandler +} = require('./cli/local-bridge'); const { createOpenclawConfigController } = require('./cli/openclaw-config'); @@ -188,6 +191,7 @@ const INIT_MARK_FILE = path.join(CONFIG_DIR, 'codexmate-init.json'); const BUILTIN_PROXY_SETTINGS_FILE = path.join(CONFIG_DIR, 'codexmate-proxy.json'); const BUILTIN_CLAUDE_PROXY_SETTINGS_FILE = path.join(CONFIG_DIR, 'codexmate-claude-proxy.json'); const OPENAI_BRIDGE_SETTINGS_FILE = path.join(CONFIG_DIR, 'codexmate-openai-bridge.json'); +const LOCAL_BRIDGE_SETTINGS_FILE = path.join(CONFIG_DIR, 'codexmate-local-bridge.json'); const CODEX_SESSIONS_DIR = path.join(CONFIG_DIR, 'sessions'); const SESSION_TRASH_DIR = path.join(CONFIG_DIR, 'codexmate-session-trash'); const SESSION_TRASH_FILES_DIR = path.join(SESSION_TRASH_DIR, 'files'); @@ -268,6 +272,7 @@ const DEFAULT_EXTRACT_SUFFIXES = Object.freeze(['.json']); const g_taskRunControllers = new Map(); let g_taskQueueProcessor = null; const BUILTIN_PROXY_PROVIDER_NAME = 'codexmate-proxy'; +const BUILTIN_LOCAL_PROVIDER_NAME = 'local'; const DEFAULT_BUILTIN_PROXY_SETTINGS = Object.freeze({ enabled: false, host: '127.0.0.1', @@ -330,6 +335,16 @@ const openaiBridgeHandler = createOpenaiBridgeHttpHandler({ httpsAgent: HTTPS_KEEP_ALIVE_AGENT }); +const localBridgeHandler = createLocalBridgeHttpHandler({ + readConfigFn: readConfig, + openaiBridgeFile: OPENAI_BRIDGE_SETTINGS_FILE, + localBridgeSettingsFile: LOCAL_BRIDGE_SETTINGS_FILE, + expectedToken: typeof process.env.CODEXMATE_HTTP_TOKEN === 'string' ? process.env.CODEXMATE_HTTP_TOKEN.trim() : '', + maxBodySize: MAX_API_BODY_SIZE, + httpAgent: HTTP_KEEP_ALIVE_AGENT, + httpsAgent: HTTPS_KEEP_ALIVE_AGENT +}); + function resolveWebPort() { const raw = process.env.CODEXMATE_PORT; if (!raw) return DEFAULT_WEB_PORT; @@ -589,16 +604,17 @@ model_auto_compact_token_limit = ${DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT} disable_response_storage = true approval_policy = "never" sandbox_mode = "danger-full-access" -model_provider = "maxx" +model_provider = "local" personality = "pragmatic" web_search = "live" -[model_providers.maxx] -name = "maxx" -base_url = "https://maxx-direct.cloverstd.com" +[model_providers.local] +name = "local" +base_url = "http://127.0.0.1:3737/bridge/local/v1" wire_api = "responses" -requires_openai_auth = false -preferred_auth_method = "sk-" +requires_openai_auth = true +preferred_auth_method = "codexmate" +codexmate_bridge = "local" request_max_retries = 4 stream_max_retries = 10 stream_idle_timeout_ms = 300000 @@ -620,12 +636,16 @@ function isBuiltinProxyProvider(providerName) { return typeof providerName === 'string' && providerName.trim().toLowerCase() === BUILTIN_PROXY_PROVIDER_NAME.toLowerCase(); } +function isLocalProvider(providerName) { + return typeof providerName === 'string' && providerName.trim().toLowerCase() === BUILTIN_LOCAL_PROVIDER_NAME.toLowerCase(); +} + function isReservedProviderNameForCreation(providerName) { - return false; + return isLocalProvider(providerName); } function isBuiltinManagedProvider(providerName) { - return isBuiltinProxyProvider(providerName); + return isBuiltinProxyProvider(providerName) || isLocalProvider(providerName); } function isNonDeletableProvider(providerName) { @@ -1661,6 +1681,7 @@ const { DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT, CODEXMATE_MANAGED_MARKER, BUILTIN_PROXY_PROVIDER_NAME, + BUILTIN_LOCAL_PROVIDER_NAME, EMPTY_CONFIG_FALLBACK_TEMPLATE }); @@ -2059,7 +2080,7 @@ function addProviderToConfig(params = {}) { return { error: '提供商名称不可用' }; } if (isBuiltinProxyProvider(name) && !allowManaged) { - return { error: 'codexmate-proxy 为保留名称,不可手动添加' }; + return { error: `${"codexmate-proxy"} 为保留名称,不可手动添加` }; // keep literal for codexmate-proxy } ensureConfigDir(); @@ -2159,7 +2180,7 @@ function updateProviderInConfig(params = {}) { return { error: 'URL 仅支持 http/https' }; } if (isNonEditableProvider(name) && !allowManaged) { - return { error: 'codexmate-proxy 为保留名称,不可编辑' }; + return { error: `${name} 为保留名称,不可编辑` }; } try { @@ -2174,7 +2195,7 @@ function deleteProviderFromConfig(params = {}) { const name = typeof params.name === 'string' ? params.name.trim() : ''; if (!name) return { error: '名称不能为空' }; if (isNonDeletableProvider(name)) { - return { error: 'codexmate-proxy 为保留名称,不可删除' }; + return { error: `${name} 为保留名称,不可删除` }; } if (!fs.existsSync(CONFIG_FILE)) { return { error: 'config.toml 不存在' }; @@ -2202,7 +2223,7 @@ function deleteProviderFromConfig(params = {}) { function performProviderDeletion(name, options = {}) { const silent = !!options.silent; if (isNonDeletableProvider(name)) { - const msg = 'codexmate-proxy 为保留名称,不可删除'; + const msg = `${name} 为保留名称,不可删除`; if (!silent) console.error('错误:', msg); return { error: msg }; } @@ -5423,6 +5444,100 @@ async function ensureBuiltinProxyForCodexDefault(params = {}) { return { error: '该功能已移除' }; } +function readLocalBridgeSettings() { + const defaults = { enabled: false, lastActiveProvider: '', lastModel: '', excludedProviders: [] }; + try { + if (!fs.existsSync(LOCAL_BRIDGE_SETTINGS_FILE)) return defaults; + const raw = JSON.parse(fs.readFileSync(LOCAL_BRIDGE_SETTINGS_FILE, 'utf-8')); + return { + enabled: !!raw.enabled, + lastActiveProvider: typeof raw.lastActiveProvider === 'string' ? raw.lastActiveProvider.trim() : '', + lastModel: typeof raw.lastModel === 'string' ? raw.lastModel.trim() : '', + excludedProviders: Array.isArray(raw.excludedProviders) ? raw.excludedProviders.filter(p => typeof p === 'string') : [] + }; + } catch (e) { + return defaults; + } +} + +function writeLocalBridgeSettings(settings) { + fs.writeFileSync(LOCAL_BRIDGE_SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf-8'); +} + +function toggleLocalBridgeProvider(params = {}) { + const enable = !!params.enable; + const settings = readLocalBridgeSettings(); + try { + const config = readConfig(); + const currentProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : ''; + const currentModel = typeof config.model === 'string' ? config.model.trim() : ''; + + if (enable) { + if (currentProvider === 'local') return { success: true, enabled: true, notice: '已启用 local 转换' }; + settings.lastActiveProvider = currentProvider; + settings.lastModel = currentModel; + settings.enabled = true; + writeLocalBridgeSettings(settings); + let content = fs.readFileSync(CONFIG_FILE, 'utf-8'); + content = content.replace(/^(model_provider\s*=\s*)(["']).*?(["'])/m, `$1$2local$3`); + writeConfig(content); + return { success: true, enabled: true, previousProvider: currentProvider }; + } else { + if (currentProvider !== 'local') { + settings.enabled = false; + writeLocalBridgeSettings(settings); + return { success: true, enabled: false, notice: 'local 转换未启用' }; + } + const restoreProvider = settings.lastActiveProvider || ''; + if (!restoreProvider) { + settings.enabled = false; + writeLocalBridgeSettings(settings); + return { success: true, enabled: false, notice: '已关闭 local 转换(无历史 provider 可恢复)' }; + } + let content = fs.readFileSync(CONFIG_FILE, 'utf-8'); + content = content.replace(/^(model_provider\s*=\s*)(["']).*?(["'])/m, `$1$2${restoreProvider}$3`); + if (settings.lastModel) { + content = content.replace(/^(model\s*=\s*)(["']).*?(["'])/m, `$1$2${settings.lastModel}$3`); + } + writeConfig(content); + settings.enabled = false; + writeLocalBridgeSettings(settings); + return { success: true, enabled: false, restoredProvider: restoreProvider, restoredModel: settings.lastModel }; + } + } catch (e) { + return { error: e && e.message ? e.message : '操作失败' }; + } +} + +function getLocalBridgeStatus() { + const settings = readLocalBridgeSettings(); + let currentProvider = ''; + try { + const config = readConfig(); + currentProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : ''; + } catch (e) { /* ignore */ } + return { + enabled: settings.enabled, + active: currentProvider === 'local', + excludedProviders: settings.excludedProviders, + lastActiveProvider: settings.lastActiveProvider, + lastModel: settings.lastModel + }; +} + +function setLocalBridgeExcludedProviders(params = {}) { + const names = Array.isArray(params.names) ? params.names.filter(n => typeof n === 'string' && n.trim()) : []; + const settings = readLocalBridgeSettings(); + settings.excludedProviders = names; + writeLocalBridgeSettings(settings); + return { success: true, excludedProviders: names }; +} + +function getLocalBridgeExcludedProviders() { + const settings = readLocalBridgeSettings(); + return { excludedProviders: settings.excludedProviders }; +} + function removeClaudeSessionIndexEntry(indexPath, sessionFilePath, sessionId) { if (!indexPath || !fs.existsSync(indexPath)) { return { removed: false, entry: null }; @@ -8132,8 +8247,8 @@ function cmdAdd(name, baseUrl, apiKey, silent = false, options = {}) { throw new Error('提供商名称不可用'); } if (isBuiltinProxyProvider(providerName)) { - if (!silent) console.error('错误: codexmate-proxy 为保留名称,不可手动添加'); - throw new Error('codexmate-proxy 为保留名称,不可手动添加'); + if (!silent) console.error(`错误: ${providerName} 为保留名称,不可手动添加`); + throw new Error(`${providerName} 为保留名称,不可手动添加`); } if (!isValidHttpUrl(providerBaseUrl)) { if (!silent) console.error('错误: URL 仅支持 http/https'); @@ -8229,7 +8344,7 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) { throw new Error('提供商名称必填'); } if (isNonEditableProvider(name) && !allowManaged) { - const msg = 'codexmate-proxy 为保留名称,不可编辑'; + const msg = `${name} 为保留名称,不可编辑`; if (!silent) console.error(`错误: ${msg}`); throw new Error(msg); } @@ -9961,6 +10076,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }); res.end(body, 'utf-8'); }; + if (typeof localBridgeHandler === 'function' && localBridgeHandler(req, res)) { + return; + } if (typeof openaiBridgeHandler === 'function' && openaiBridgeHandler(req, res)) { return; } @@ -10497,6 +10615,18 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'proxy-apply-provider': result = applyBuiltinProxyProvider(params || {}); break; + case 'local-bridge-toggle': + result = toggleLocalBridgeProvider(params || {}); + break; + case 'local-bridge-status': + result = getLocalBridgeStatus(); + break; + case 'local-bridge-set-excluded': + result = setLocalBridgeExcludedProviders(params || {}); + break; + case 'local-bridge-get-excluded': + result = getLocalBridgeExcludedProviders(); + break; case 'workflow-list': result = listWorkflowDefinitions(); break; diff --git a/cli/config-bootstrap.js b/cli/config-bootstrap.js index 63b9a997..f952d4cb 100644 --- a/cli/config-bootstrap.js +++ b/cli/config-bootstrap.js @@ -30,6 +30,7 @@ function createConfigBootstrapController(deps = {}) { DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT, CODEXMATE_MANAGED_MARKER, BUILTIN_PROXY_PROVIDER_NAME, + BUILTIN_LOCAL_PROVIDER_NAME, EMPTY_CONFIG_FALLBACK_TEMPLATE } = deps; @@ -58,6 +59,7 @@ function createConfigBootstrapController(deps = {}) { if (!Array.isArray(DEFAULT_MODELS)) throw new Error('createConfigBootstrapController 缺少 DEFAULT_MODELS'); if (!CODEXMATE_MANAGED_MARKER) throw new Error('createConfigBootstrapController 缺少 CODEXMATE_MANAGED_MARKER'); if (!BUILTIN_PROXY_PROVIDER_NAME) throw new Error('createConfigBootstrapController 缺少 BUILTIN_PROXY_PROVIDER_NAME'); + if (!BUILTIN_LOCAL_PROVIDER_NAME) throw new Error('createConfigBootstrapController 缺少 BUILTIN_LOCAL_PROVIDER_NAME'); if (typeof EMPTY_CONFIG_FALLBACK_TEMPLATE !== 'string') throw new Error('createConfigBootstrapController 缺少 EMPTY_CONFIG_FALLBACK_TEMPLATE'); let initNotice = ''; @@ -118,17 +120,18 @@ function createConfigBootstrapController(deps = {}) { return `${CODEXMATE_MANAGED_MARKER} # codexmate-initialized-at: ${initializedAt} -model_provider = "openai" +model_provider = "local" model = "${defaultModel}" model_context_window = ${DEFAULT_MODEL_CONTEXT_WINDOW} model_auto_compact_token_limit = ${DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT} -[model_providers.openai] -name = "openai" -base_url = "https://api.openai.com/v1" +[model_providers.local] +name = "local" +base_url = "http://127.0.0.1:3737/bridge/local/v1" wire_api = "responses" -requires_openai_auth = false -preferred_auth_method = "" +requires_openai_auth = true +preferred_auth_method = "codexmate" +codexmate_bridge = "local" request_max_retries = 4 stream_max_retries = 10 stream_idle_timeout_ms = 300000 @@ -145,9 +148,8 @@ stream_idle_timeout_ms = 300000 const currentProvider = typeof safeConfig.model_provider === 'string' ? safeConfig.model_provider.trim() : ''; const hasRemovedBuiltin = !!(providers && providers[BUILTIN_PROXY_PROVIDER_NAME]); const currentIsRemovedBuiltin = currentProvider === BUILTIN_PROXY_PROVIDER_NAME; - const currentIsRemovedVirtualLocal = currentProvider === 'local' && !(providers && isPlainObject(providers.local)); - if (!hasRemovedBuiltin && !currentIsRemovedBuiltin && !currentIsRemovedVirtualLocal) { + if (!hasRemovedBuiltin && !currentIsRemovedBuiltin) { return safeConfig; } @@ -163,11 +165,26 @@ stream_idle_timeout_ms = 300000 return { ...safeConfig, model_providers: nextProviders, - model_provider: (currentIsRemovedBuiltin || currentIsRemovedVirtualLocal) ? fallbackProvider : safeConfig.model_provider, - model: (currentIsRemovedBuiltin || currentIsRemovedVirtualLocal) ? fallbackModel : safeConfig.model + model_provider: currentIsRemovedBuiltin ? fallbackProvider : safeConfig.model_provider, + model: currentIsRemovedBuiltin ? fallbackModel : safeConfig.model }; } + function ensureLocalProviderSection() { + if (!fs.existsSync(CONFIG_FILE)) return; + let content; + try { + content = fs.readFileSync(CONFIG_FILE, 'utf-8'); + } catch (e) { + return; + } + // Check if [model_providers.local] section already exists + if (/\[model_providers\.local\]/.test(content)) return; + + const localSection = `\n[model_providers.local]\nname = "local"\nbase_url = "http://127.0.0.1:3737/bridge/local/v1"\nwire_api = "responses"\nrequires_openai_auth = true\npreferred_auth_method = "codexmate"\ncodexmate_bridge = "local"\nrequest_max_retries = 4\nstream_max_retries = 10\nstream_idle_timeout_ms = 300000\n`; + fs.appendFileSync(CONFIG_FILE, localSection, 'utf-8'); + } + function readConfigOrVirtualDefault() { if (fs.existsSync(CONFIG_FILE)) { try { @@ -260,7 +277,7 @@ stream_idle_timeout_ms = 300000 ensureConfigDir(); const initializedAt = new Date().toISOString(); - const defaultProvider = 'openai'; + const defaultProvider = 'local'; const defaultModel = DEFAULT_MODELS[0] || 'gpt-4'; const forceResetExistingConfig = process.env.CODEXMATE_FORCE_RESET_EXISTING_CONFIG === '1'; const mark = readJsonFile(INIT_MARK_FILE, null); @@ -273,6 +290,7 @@ stream_idle_timeout_ms = 300000 initNotice = '检测到配置缺失,已自动重建默认配置。'; return { notice: initNotice }; } + ensureLocalProviderSection(); ensureSupportFiles(defaultProvider, defaultModel); return { notice: '' }; } @@ -338,7 +356,7 @@ stream_idle_timeout_ms = 300000 function resetConfigToDefault() { ensureConfigDir(); const initializedAt = new Date().toISOString(); - const defaultProvider = 'openai'; + const defaultProvider = 'local'; const defaultModel = DEFAULT_MODELS[0] || 'gpt-4'; let backupFile = ''; diff --git a/cli/local-bridge.js b/cli/local-bridge.js new file mode 100644 index 00000000..6185917e --- /dev/null +++ b/cli/local-bridge.js @@ -0,0 +1,324 @@ +const fs = require('fs'); +const { URL } = require('url'); +const { + readOpenaiBridgeSettings, + convertResponsesRequestToChatCompletions, + streamChatCompletionsAsResponsesSse, + proxyRequestJson, + ensureResponseMetadata, + sendResponsesSse, + extractAuthorizationToken, + readRequestBody, + parseJsonOrError, + extractChatCompletionResult, + buildResponsesPayloadFromChatResult, + retryTransientRequest, + shouldFallbackFromUpstreamResponses, + isTransientNetworkError, + isLoopbackAddress +} = require('./openai-bridge'); +const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils'); + +const BUILTIN_PROXY_PROVIDER_NAME = 'codexmate-proxy'; +const BUILTIN_LOCAL_PROVIDER_NAME = 'local'; +const CIRCUIT_BREAKER_THRESHOLD = 3; +const CIRCUIT_BREAKER_COOLDOWN_MS = 5 * 60 * 1000; + +function buildUpstreamPool(readConfigFn, openaiBridgeFile, excludedProviders) { + let config; + try { config = readConfigFn(); } catch (e) { return { error: '读取配置失败' }; } + const providers = (config && typeof config.model_providers === 'object' && !Array.isArray(config.model_providers)) + ? config.model_providers : {}; + const pool = []; + const excludedSet = new Set( + (Array.isArray(excludedProviders) ? excludedProviders : []) + .filter(n => typeof n === 'string' && n.trim()) + .map(n => n.trim().toLowerCase()) + ); + for (const [name, p] of Object.entries(providers)) { + if (!p || typeof p !== 'object') continue; + const lower = name.toLowerCase(); + if (lower === BUILTIN_LOCAL_PROVIDER_NAME || lower === BUILTIN_PROXY_PROVIDER_NAME) continue; + if (excludedSet.has(lower)) continue; + const bridge = typeof p.codexmate_bridge === 'string' ? p.codexmate_bridge.trim() : ''; + if (bridge === 'local') continue; // avoid loop: local→local + const baseUrl = typeof p.base_url === 'string' ? p.base_url.trim() : ''; + if (!isValidHttpUrl(normalizeBaseUrl(baseUrl))) continue; + const authMethod = typeof p.preferred_auth_method === 'string' ? p.preferred_auth_method.trim() : ''; + pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), authMethod, requiresOpenaiAuth: !!p.requires_openai_auth }); + } + if (pool.length === 0) return { error: '请先添加上游 provider' }; + return { pool }; +} + +function resolveUpstreamAuth(entry, openaiBridgeFile, reqAuthToken) { + if (entry.authMethod === 'codexmate' || entry.requiresOpenaiAuth) { + const token = reqAuthToken || ''; + return token ? (token.startsWith('sk-') ? `Bearer ${token}` : `Bearer ${token}`) : ''; + } + if (entry.authMethod === 'openai-bridge') { + const settings = readOpenaiBridgeSettings(openaiBridgeFile); + const upstream = settings.providers ? settings.providers[entry.name] : null; + if (upstream && upstream.apiKey) { + return upstream.apiKey.startsWith('Bearer ') ? upstream.apiKey : `Bearer ${upstream.apiKey}`; + } + } + return ''; +} + +function createLocalBridgeHttpHandler(options = {}) { + const readConfigFn = options.readConfigFn; + const openaiBridgeFile = options.openaiBridgeFile; + const expectedToken = typeof options.expectedToken === 'string' ? options.expectedToken.trim() : ''; + const maxBodySize = Number.isFinite(options.maxBodySize) ? options.maxBodySize : 0; + const httpAgent = options.httpAgent; + const httpsAgent = options.httpsAgent; + const maxUpstreamBytes = Number.isFinite(options.maxUpstreamBytes) && options.maxUpstreamBytes > 0 + ? Math.floor(options.maxUpstreamBytes) + : Math.max(16 * 1024 * 1024, maxBodySize > 0 ? maxBodySize * 4 : 0); + + if (typeof readConfigFn !== 'function') throw new Error('createLocalBridgeHttpHandler 缺少 readConfigFn'); + + const circuitState = new Map(); // name → { failures, openUntil } + let rrIndex = 0; + + function pickUpstream(pool) { + const now = Date.now(); + for (let i = 0; i < pool.length; i++) { + const idx = rrIndex++ % pool.length; + const entry = pool[idx]; + const st = circuitState.get(entry.name); + if (st && st.openUntil > now) continue; // circuit open + return { entry, idx }; + } + // all circuits open, reset and retry first + circuitState.clear(); + return { entry: pool[0], idx: 0 }; + } + + function recordFailure(name) { + let st = circuitState.get(name); + if (!st) { st = { failures: 0, openUntil: 0 }; circuitState.set(name, st); } + st.failures++; + if (st.failures >= CIRCUIT_BREAKER_THRESHOLD) { + st.openUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN_MS; + } + } + + function recordSuccess(name) { + circuitState.delete(name); + } + + const localBridgeSettingsFile = options.localBridgeSettingsFile || ''; + + function readExcludedProviders() { + if (!localBridgeSettingsFile) return []; + try { + if (!fs.existsSync(localBridgeSettingsFile)) return []; + const raw = JSON.parse(fs.readFileSync(localBridgeSettingsFile, 'utf-8')); + const excluded = Array.isArray(raw.excludedProviders) + ? raw.excludedProviders.filter(n => typeof n === 'string' && n.trim()) + : []; + // 解二: auto-exclude lastActiveProvider + const last = typeof raw.lastActiveProvider === 'string' ? raw.lastActiveProvider.trim() : ''; + if (last && !excluded.some(n => n.toLowerCase() === last.toLowerCase())) { + excluded.push(last); + } + return excluded; + } catch (e) { return []; } + } + + const handler = (req, res) => { + let parsedUrl; + try { parsedUrl = new URL(req.url || '/', 'http://localhost'); } catch (_) { return false; } + const pathname = parsedUrl.pathname || '/'; + if (!pathname.startsWith('/bridge/local/')) return false; + const suffix = pathname.replace(/^\/bridge\/local\/?/, ''); + if (!suffix.startsWith('v1')) return false; + + void (async () => { + try { + const token = extractAuthorizationToken(req); + const remoteAddr = req && req.socket ? req.socket.remoteAddress : ''; + const isLoopback = isLoopbackAddress(remoteAddr); + if (!isLoopback && !expectedToken) { + res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)' })); + return; + } + if (!token && !isLoopback) { + res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + if (!isLoopback && token && token !== expectedToken) { + res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + const poolResult = buildUpstreamPool(readConfigFn, openaiBridgeFile, readExcludedProviders()); + if (poolResult.error) { + res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: poolResult.error })); + return; + } + const pool = poolResult.pool; + + const { entry, idx } = pickUpstream(pool); + const authHeader = resolveUpstreamAuth(entry, openaiBridgeFile, token); + + const normalizedSuffix = suffix.replace(/^v1\/?/, ''); + const upstreamBase = entry.baseUrl.replace(/\/+$/, ''); + + if (!normalizedSuffix) { + if ((req.method || 'GET').toUpperCase() !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ object: 'codexmate.local_bridge', provider: entry.name, status: 'ok', pool: pool.map(p => p.name) })); + return; + } + + if (normalizedSuffix === 'responses' && (req.method || 'GET').toUpperCase() === 'POST') { + 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 responsesRequest = parsed.value; + const wantsSse = !!(responsesRequest && responsesRequest.stream); + const upstreamResponsesUrl = joinApiUrl(upstreamBase, 'responses'); + const upstreamResponsesResult = await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, { + method: 'POST', + body: bodyResult.body, + headers: { ...(authHeader ? { Authorization: authHeader } : {}) }, + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + })); + + if (upstreamResponsesResult.ok && upstreamResponsesResult.status < 400) { + recordSuccess(entry.name); + const upstreamPayload = parseJsonOrError(upstreamResponsesResult.bodyText); + if (upstreamPayload.error) { + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream parse failed: ${upstreamPayload.error}` })); + return; + } + if (wantsSse) { + res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' }); + if (typeof res.flushHeaders === 'function') res.flushHeaders(); + sendResponsesSse(res, upstreamPayload.value); + res.end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(ensureResponseMetadata(upstreamPayload.value))); + return; + } + + if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 400 && !shouldFallbackFromUpstreamResponses(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) { + recordFailure(entry.name); + res.writeHead(upstreamResponsesResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' })); + return; + } + + if (!upstreamResponsesResult.ok) { + recordFailure(entry.name); + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResponsesResult.error}` })); + return; + } + + // fallthrough to chat/completions conversion + recordSuccess(entry.name); + const converted = convertResponsesRequestToChatCompletions(responsesRequest); + if (converted.error) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: converted.error })); + return; + } + const chatUrl = joinApiUrl(upstreamBase, 'chat/completions'); + const chatResult = await retryTransientRequest(() => proxyRequestJson(chatUrl, { + method: 'POST', + body: JSON.stringify(converted.chat), + headers: { ...(authHeader ? { Authorization: authHeader } : {}), 'Content-Type': 'application/json' }, + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + })); + if (!chatResult.ok) { + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream request failed: ${chatResult.error}` })); + return; + } + const chatJson = parseJsonOrError(chatResult.bodyText); + if (chatResult.status >= 400) { + res.writeHead(chatResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(chatResult.bodyText || JSON.stringify({ error: 'Upstream error' })); + return; + } + if (chatJson.error) { + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream parse failed: ${chatJson.error}` })); + return; + } + const extracted = extractChatCompletionResult(chatJson.value); + const text = extracted && typeof extracted.text === 'string' ? extracted.text : ''; + const toolCalls = extracted && Array.isArray(extracted.toolCalls) ? extracted.toolCalls : []; + const model = typeof converted.chat.model === 'string' ? converted.chat.model : ''; + const responsesPayload = buildResponsesPayloadFromChatResult(model, text, toolCalls, chatJson.value); + if (wantsSse) { + res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' }); + if (typeof res.flushHeaders === 'function') res.flushHeaders(); + sendResponsesSse(res, responsesPayload); + res.end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(ensureResponseMetadata(responsesPayload))); + return; + } + + // passthrough for other v1/* paths + const upstreamUrl = joinApiUrl(upstreamBase, normalizedSuffix); + const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, { + method: req.method || 'GET', + body: null, + headers: { ...(authHeader ? { Authorization: authHeader } : {}) }, + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + })); + if (!upstreamResult.ok) { + recordFailure(entry.name); + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` })); + return; + } + recordSuccess(entry.name); + res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(upstreamResult.bodyText); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' })); + } + })(); + return true; + }; + + return handler; +} + +module.exports = { createLocalBridgeHttpHandler }; diff --git a/cli/openai-bridge.js b/cli/openai-bridge.js index e0a2f62b..4b4f5f3c 100644 --- a/cli/openai-bridge.js +++ b/cli/openai-bridge.js @@ -1,11 +1,16 @@ const http = require('http'); const https = require('https'); const crypto = require('crypto'); +const { StringDecoder } = require('string_decoder'); const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils'); const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils'); const DEFAULT_BRIDGE_TOKEN = 'codexmate'; const SETTINGS_VERSION = 1; +// 推理模型 reasoning 阶段可能长时间无字节输出,需匹配 codex 的 stream_idle_timeout_ms=300000。 +const STREAM_IDLE_TIMEOUT_MS = 5 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 5 * 60 * 1000; +const RESPONSES_UNSUPPORTED_TTL_MS = 30 * 60 * 1000; function normalizeText(value) { return typeof value === 'string' ? value.trim() : ''; @@ -748,6 +753,30 @@ function shouldFallbackFromUpstreamResponses(status, bodyText) { return false; } +// 仅识别"端点级别不支持"——可缓存,与 per-request 的 tool 格式错误区分。 +function isResponsesEndpointUnsupported(status, bodyText) { + if (!Number.isFinite(status)) return false; + if (status === 404 || status === 405 || status === 501) return true; + const text = String(bodyText || ''); + if (!text) return false; + if (/not implemented/i.test(text)) return true; + if (/convert_request_failed/i.test(text)) return true; + if (/unknown (endpoint|route)/i.test(text)) return true; + if (/unsupported.*\/?v1\/responses/i.test(text)) return true; + if (/does not support.*responses/i.test(text)) return true; + try { + const parsed = JSON.parse(text); + const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : ''; + const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : ''; + if (code === 'convert_request_failed') return true; + if (/not implemented/i.test(msg)) return true; + if (/unknown (endpoint|route)/i.test(msg)) return true; + if (/unsupported.*\/?v1\/responses/i.test(msg)) return true; + if (/does not support.*responses/i.test(msg)) return true; + } catch (_) {} + return false; +} + function isLoopbackAddress(address) { if (!address) return false; const value = String(address); @@ -989,7 +1018,7 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) { } const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, Number(options.timeoutMs)) - : 30000; + : STREAM_IDLE_TIMEOUT_MS; const res = options.res; const fallbackModel = typeof options.model === 'string' ? options.model : ''; @@ -1108,6 +1137,7 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) { }); let buffer = ''; + const utf8Decoder = new StringDecoder('utf8'); const handleEventBlock = (block) => { const dataLines = String(block || '') .split(/\r?\n/) @@ -1135,7 +1165,7 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) { upstreamRes.on('data', (chunk) => { if (!chunk) return; - buffer += chunk.toString('utf-8'); + buffer += utf8Decoder.write(chunk); let boundary = buffer.search(/\r?\n\r?\n/); while (boundary >= 0) { const block = buffer.slice(0, boundary); @@ -1146,6 +1176,7 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) { } }); upstreamRes.on('end', () => { + buffer += utf8Decoder.end(); if (buffer.trim()) handleEventBlock(buffer); if (!state.finished && !state.sawDone && !state.sawFinishReason) { failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]'); @@ -1192,7 +1223,7 @@ async function proxyRequestJson(targetUrl, options = {}) { const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, Number(options.timeoutMs)) - : 30000; + : REQUEST_TIMEOUT_MS; return new Promise((resolve) => { let settled = false; const finish = (value) => { @@ -1264,6 +1295,27 @@ function createOpenaiBridgeHttpHandler(options = {}) { throw new Error('createOpenaiBridgeHttpHandler 缺少 settingsFile'); } + // 端点不支持的缓存(per-baseUrl, TTL 30 分钟):避免每次非流式请求重复探测 /v1/responses。 + const unsupportedResponses = new Map(); + const isResponsesKnownUnsupported = (baseUrl) => { + if (!baseUrl) return false; + const entry = unsupportedResponses.get(baseUrl); + if (!entry) return false; + if (entry.expiresAt <= Date.now()) { + unsupportedResponses.delete(baseUrl); + return false; + } + return true; + }; + const markResponsesUnsupported = (baseUrl) => { + if (!baseUrl) return; + unsupportedResponses.set(baseUrl, { expiresAt: Date.now() + RESPONSES_UNSUPPORTED_TTL_MS }); + }; + const clearResponsesUnsupported = (baseUrl) => { + if (!baseUrl) return; + unsupportedResponses.delete(baseUrl); + }; + const matchPath = (requestPath) => { const normalized = String(requestPath || ''); const prefix = '/bridge/openai/'; @@ -1443,20 +1495,25 @@ function createOpenaiBridgeHttpHandler(options = {}) { // Maxx-style behavior: prefer upstream /responses if supported. // Fallback to /chat/completions conversion when upstream does not implement /responses (404/405). + // 已知不支持的上游:直接跳过探测,节省一次 round-trip。 + const skipResponsesProbe = isResponsesKnownUnsupported(upstream.baseUrl); const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses'); - const upstreamResponsesResult = await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, { - method: 'POST', - body: toUpstreamNonStreamingResponsesPayload(responsesRequest), - headers: { - ...(authHeader ? { Authorization: authHeader } : {}), - ...upstreamHeaders - }, - maxBytes: maxUpstreamBytes, - httpAgent, - httpsAgent - })); + const upstreamResponsesResult = skipResponsesProbe + ? { ok: true, status: 404, bodyText: '' } + : await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, { + method: 'POST', + body: toUpstreamNonStreamingResponsesPayload(responsesRequest), + headers: { + ...(authHeader ? { Authorization: authHeader } : {}), + ...upstreamHeaders + }, + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + })); if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) { + clearResponsesUnsupported(upstream.baseUrl); const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText); if (upstreamJson.error) { res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); @@ -1488,6 +1545,9 @@ function createOpenaiBridgeHttpHandler(options = {}) { res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' })); return; } + if (!skipResponsesProbe && isResponsesEndpointUnsupported(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) { + markResponsesUnsupported(upstream.baseUrl); + } // fallthrough to chat/completions conversion } @@ -1572,5 +1632,22 @@ module.exports = { readOpenaiBridgeSettings, upsertOpenaiBridgeProvider, resolveOpenaiBridgeUpstream, - createOpenaiBridgeHttpHandler + createOpenaiBridgeHttpHandler, + // exported for local-bridge reuse + convertResponsesRequestToChatCompletions, + streamChatCompletionsAsResponsesSse, + proxyRequestJson, + ensureResponseMetadata, + sendResponsesSse, + extractAuthorizationToken, + readRequestBody, + parseJsonOrError, + extractChatCompletionResult, + buildResponsesPayloadFromChatResult, + retryTransientRequest, + normalizeOpenaiUpstreamBaseUrl, + extractResponsesOutputText, + shouldFallbackFromUpstreamResponses, + isTransientNetworkError, + isLoopbackAddress }; diff --git a/tests/e2e/test-config.js b/tests/e2e/test-config.js index 1deb0bca..723861a9 100644 --- a/tests/e2e/test-config.js +++ b/tests/e2e/test-config.js @@ -312,12 +312,7 @@ preferred_auth_method = "shadow-key" assert(addProviderDup.error, 'add-provider should reject duplicate provider'); const addProviderLocal = await api('add-provider', { name: 'local', url: mockProviderUrl, key: 'sk-local-e2e' }); - assert(addProviderLocal.success === true, 'add-provider should allow local as a normal provider name'); - const apiListAfterLocalAdd = await api('list'); - assert( - apiListAfterLocalAdd.providers.some((item) => item && item.name === 'local' && item.url === mockProviderUrl), - 'add-provider should persist local as a normal provider' - ); + assert(addProviderLocal.error, 'add-provider should reject reserved local provider name'); const addProviderInvalidName = await api('add-provider', { name: 'bad name', url: mockProviderUrl }); assert(addProviderInvalidName.error, 'add-provider should reject invalid provider name'); @@ -1461,11 +1456,10 @@ preferred_auth_method = "shadow-key" assert(deleteProviderResult.success === true, 'delete-provider failed'); const deleteLocalProviderResult = await api('delete-provider', { name: 'local' }); - assert(deleteLocalProviderResult.success === true, 'delete-provider should remove normal local provider'); + assert(deleteLocalProviderResult.error, 'delete-provider should reject reserved local provider'); const apiListAfterDelete = await api('list'); assert(!apiListAfterDelete.providers.some(p => p.name === 'e2e-api'), 'delete-provider not reflected in list'); - assert(!apiListAfterDelete.providers.some(p => p.name === 'local'), 'delete-provider should remove local provider'); // ========== Recent Configs Tests ========== const recentConfigs = await api('get-recent-configs'); diff --git a/tests/unit/openai-bridge-upstream-responses.test.mjs b/tests/unit/openai-bridge-upstream-responses.test.mjs index 539426e6..2de7bd08 100644 --- a/tests/unit/openai-bridge-upstream-responses.test.mjs +++ b/tests/unit/openai-bridge-upstream-responses.test.mjs @@ -1016,3 +1016,127 @@ test('openai-bridge SSE fast path also merges developer-role AGENTS.md into lead await upstream.close(); await rm(tmpDir, { recursive: true, force: true }); }); + +test('openai-bridge skips /v1/responses probe after upstream marks it unsupported', async () => { + let responsesHits = 0; + let chatHits = 0; + const upstream = http.createServer((req, res) => { + if (req.url === '/v1/responses' && req.method === 'POST') { + responsesHits += 1; + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Unknown endpoint', code: 'unknown_endpoint' } })); + return; + } + if (req.url === '/v1/chat/completions' && req.method === 'POST') { + chatHits += 1; + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'cc_x', + model: 'gpt-test', + choices: [{ message: { role: 'assistant', content: 'ok' } }] + })); + }); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + const { port: upstreamPort } = await listen(upstream); + + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-')); + const settingsFile = path.join(tmpDir, 'bridge.json'); + await writeFile(settingsFile, JSON.stringify({ + version: 1, + providers: { + test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, apiKey: 'sk-upstream' } + } + }), 'utf-8'); + + const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' }); + const bridge = http.createServer((req, res) => { + if (!handler(req, res)) { + res.statusCode = 404; + res.end('not handled'); + } + }); + const { port: bridgePort } = await listen(bridge); + + const url = `http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/responses`; + const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer codexmate' }; + const body = { model: 'gpt-test', input: 'ping', stream: false }; + + const first = await requestText(url, { method: 'POST', headers, body }); + assert.equal(first.status, 200); + assert.equal(responsesHits, 1, 'first call should probe /v1/responses'); + assert.equal(chatHits, 1, 'first call should fall back to chat/completions'); + + const second = await requestText(url, { method: 'POST', headers, body }); + assert.equal(second.status, 200); + assert.equal(responsesHits, 1, 'second call should skip /v1/responses probe (cache hit)'); + assert.equal(chatHits, 2, 'second call should go directly to chat/completions'); + + await bridge.close(); + await upstream.close(); + await rm(tmpDir, { recursive: true, force: true }); +}); + +test('openai-bridge preserves multibyte UTF-8 deltas split across chunk boundaries', async () => { + const upstream = http.createServer((req, res) => { + if (req.url === '/v1/chat/completions' && req.method === 'POST') { + res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' }); + const fullEvent = 'data: {"id":"x","model":"gpt-test","choices":[{"delta":{"content":"御坂"}}]}\n\n'; + const fullBytes = Buffer.from(fullEvent, 'utf-8'); + // 御 (U+5FA1) 占 3 字节,从偏移 67 起;故意在 68 处切——切到 "御" 中间。 + const splitAt = fullBytes.indexOf(Buffer.from('御', 'utf-8')) + 1; + const firstHalf = fullBytes.slice(0, splitAt); + const secondHalf = fullBytes.slice(splitAt); + res.write(firstHalf); + setTimeout(() => { + res.write(secondHalf); + res.end('data: [DONE]\n\n'); + }, 10); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + const { port: upstreamPort } = await listen(upstream); + + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-')); + const settingsFile = path.join(tmpDir, 'bridge.json'); + await writeFile(settingsFile, JSON.stringify({ + version: 1, + providers: { + test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, apiKey: 'sk-upstream' } + } + }), 'utf-8'); + + const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' }); + const bridge = http.createServer((req, res) => { + if (!handler(req, res)) { + res.statusCode = 404; + res.end('not handled'); + } + }); + const { port: bridgePort } = await listen(bridge); + + const sse = await requestText(`http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/responses`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + 'Authorization': 'Bearer codexmate' + }, + body: { model: 'gpt-test', input: 'ping', stream: true } + }); + assert.equal(sse.status, 200); + assert.match(sse.text, /"delta":"御坂"/); + assert.doesNotMatch(sse.text, /�/); + + await bridge.close(); + await upstream.close(); + await rm(tmpDir, { recursive: true, force: true }); +}); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 487cb280..205b1bec 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -344,7 +344,8 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'displayProviderUrl', 'isTransformProvider', 'openCloneClaudeConfigModal', - 'openCloneProviderModal' + 'openCloneProviderModal', + 'localBridgeExcluded' ] : [ '__mainTabSwitchState', 'openclawAuthProfilesByProvider', @@ -370,7 +371,8 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'webhookTestResult', 'webhookTesting', 'openCloneClaudeConfigModal', - 'openCloneProviderModal' + 'openCloneProviderModal', + 'localBridgeExcluded' ]; const allowedMissingCurrentKeys = [ 'localProxyRunning', @@ -524,7 +526,11 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'resetTaskOrchestrationDraft', 'appendTaskWorkflowId', 'openClaudeMdEditor', - 'saveNavState' + 'saveNavState', + 'isLocalBridgeExcluded', + 'loadLocalBridgeExcluded', + 'toggleLocalBridgeExcluded', + 'localBridgeCandidateProviders' ]; allowedExtraCurrentMethodKeys.push( 'hasActiveSessionFilters', diff --git a/web-ui/app.js b/web-ui/app.js index 45595172..aafb832f 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -41,6 +41,7 @@ document.addEventListener('DOMContentLoaded', () => { modelAutoCompactTokenLimitInput: String(DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT), editingCodexBudgetField: '', providersList: [], + localBridgeExcluded: [], models: [], codexModelsLoading: false, modelsSource: 'remote', diff --git a/web-ui/modules/app.computed.dashboard.mjs b/web-ui/modules/app.computed.dashboard.mjs index e51faf60..86f11604 100644 --- a/web-ui/modules/app.computed.dashboard.mjs +++ b/web-ui/modules/app.computed.dashboard.mjs @@ -35,6 +35,7 @@ export function createDashboardComputed() { return (name) => { const target = String(name || '').trim(); if (!target) return ''; + if (target === 'local') return ''; const dict = this.currentModels && typeof this.currentModels === 'object' ? this.currentModels : {}; const fromDict = typeof dict[target] === 'string' ? dict[target].trim() : ''; if (fromDict) return fromDict; @@ -82,12 +83,20 @@ export function createDashboardComputed() { return current; }, displayProvidersList() { - const list = Array.isArray(this.providersList) ? this.providersList : []; + const list = Array.isArray(this.providersList) ? [...this.providersList] : []; + list.sort((a, b) => { + if (a.name === 'local') return -1; + if (b.name === 'local') return 1; + return 0; + }); return list; }, displayProviderUrl() { - return (provider) => getProviderDisplayUrl(provider); + return (provider) => { + if (provider && provider.name === 'local') return ''; + return getProviderDisplayUrl(provider); + }; }, isTransformProvider() { diff --git a/web-ui/modules/app.methods.codex-config.mjs b/web-ui/modules/app.methods.codex-config.mjs index e6dae4d2..0f823f4c 100644 --- a/web-ui/modules/app.methods.codex-config.mjs +++ b/web-ui/modules/app.methods.codex-config.mjs @@ -236,6 +236,10 @@ export function createCodexConfigMethods(options = {}) { } } } + + if (typeof this.loadLocalBridgeExcluded === 'function') { + this.loadLocalBridgeExcluded(); + } }, async switchProvider(name) { diff --git a/web-ui/modules/app.methods.providers.mjs b/web-ui/modules/app.methods.providers.mjs index 144ab45e..e53c400c 100644 --- a/web-ui/modules/app.methods.providers.mjs +++ b/web-ui/modules/app.methods.providers.mjs @@ -1,5 +1,6 @@ const PROVIDER_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; const RESERVED_PROXY_PROVIDER_NAME = 'codexmate-proxy'; +const RESERVED_LOCAL_PROVIDER_NAME = 'local'; function normalizeText(value) { return typeof value === 'string' ? value.trim() : ''; @@ -21,7 +22,7 @@ function isValidHttpUrl(value) { function isReservedProviderCreationNameInput(name) { const normalized = normalizeText(name).toLowerCase(); - return normalized === RESERVED_PROXY_PROVIDER_NAME; + return normalized === RESERVED_PROXY_PROVIDER_NAME || normalized === RESERVED_LOCAL_PROVIDER_NAME; } function isValidProviderNameInputValue(name) { @@ -488,6 +489,41 @@ export function createProvidersMethods(options = {}) { : null; const key = config ? config.apiKey : ''; return this.formatKey(key); + }, + + async loadLocalBridgeExcluded() { + try { + const res = await api('local-bridge-get-excluded'); + if (res && Array.isArray(res.excludedProviders)) { + this.localBridgeExcluded = res.excludedProviders; + } + } catch (e) { /* ignore */ } + }, + + async toggleLocalBridgeExcluded(providerName) { + const name = String(providerName || '').trim(); + if (!name) return; + const idx = this.localBridgeExcluded.indexOf(name); + const next = [...this.localBridgeExcluded]; + if (idx >= 0) { + next.splice(idx, 1); + } else { + next.push(name); + } + try { + const res = await api('local-bridge-set-excluded', { names: next }); + if (res && !res.error) { + this.localBridgeExcluded = next; + } + } catch (e) { /* ignore */ } + }, + + isLocalBridgeExcluded(providerName) { + return this.localBridgeExcluded.indexOf(String(providerName || '').trim()) >= 0; + }, + + localBridgeCandidateProviders() { + return (this.providersList || []).filter(p => p && p.name !== 'local' && p.name !== 'codexmate-proxy' && p.codexmate_bridge !== 'local'); } }; } diff --git a/web-ui/modules/app.methods.startup-claude.mjs b/web-ui/modules/app.methods.startup-claude.mjs index 2cfd8b83..1ef6707d 100644 --- a/web-ui/modules/app.methods.startup-claude.mjs +++ b/web-ui/modules/app.methods.startup-claude.mjs @@ -118,6 +118,7 @@ export function createStartupClaudeMethods(options = {}) { } } this.providersList = listRes.providers; + if (typeof this.loadLocalBridgeExcluded === 'function') { this.loadLocalBridgeExcluded(); } if (statusRes.configReady === false) { this.showMessage('配置已加载', 'info'); } diff --git a/web-ui/partials/index/panel-config-codex.html b/web-ui/partials/index/panel-config-codex.html index c0636780..338f8742 100644 --- a/web-ui/partials/index/panel-config-codex.html +++ b/web-ui/partials/index/panel-config-codex.html @@ -236,10 +236,10 @@ {{ provider.name }} {{ t('config.badge.system') }} -
+
{{ activeProviderModel(provider.name) || t('config.model.unset') }}
-
+
{{ displayProviderUrl(provider) || t('config.url.unset') }}
@@ -319,5 +319,19 @@
+ +
+
轮询池 — 勾选参与负载均衡的提供商
+
暂无可用上游 provider,请先添加直连 provider
+ +
+