diff --git a/cli.js b/cli.js index d3c6103c..f29e6cd3 100644 --- a/cli.js +++ b/cli.js @@ -2877,6 +2877,35 @@ function normalizeProviderCacheProviderMap(rawProviders) { return providers; } +function readClaudeProviderCacheProvider(name) { + const targetName = typeof name === 'string' ? name.trim() : ''; + if (!targetName) return null; + const cached = normalizeProviderCacheProviderMap(readProviderCacheJsonObject('claude-providers.json').providers); + const entry = cached[targetName]; + return isPlainObject(entry) ? { name: targetName, ...entry } : null; +} + +function readClaudeProviderCacheConfigs() { + const cached = normalizeProviderCacheProviderMap(readProviderCacheJsonObject('claude-providers.json').providers); + const providers = []; + for (const [name, entry] of Object.entries(cached)) { + if (!name || !isPlainObject(entry)) continue; + const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl.trim() : ''; + const model = typeof entry.model === 'string' ? entry.model.trim() : ''; + if (!baseUrl || !model) continue; + providers.push({ + name, + baseUrl, + model, + targetApi: normalizeClaudeTargetApi(entry.targetApi), + hasKey: typeof entry.apiKey === 'string' && entry.apiKey.trim().length > 0, + providerCacheRef: name, + source: 'provider-cache' + }); + } + return { providers: providers.sort((a, b) => a.name.localeCompare(b.name)) }; +} + function buildProviderCacheSyncProviders() { const configResult = readConfigOrVirtualDefault(); if (hasConfigLoadError(configResult)) { @@ -9982,21 +10011,35 @@ async function applyToClaudeSettings(config = {}) { let proxyStarted = false; try { assertToolConfigWriteAllowed('claude'); - const apiKey = (config.apiKey || '').trim(); - const targetApi = normalizeClaudeTargetApi(config.targetApi); + const providerCacheRef = typeof config.providerCacheRef === 'string' ? config.providerCacheRef.trim() : ''; + const cachedProvider = providerCacheRef ? readClaudeProviderCacheProvider(providerCacheRef) : null; + if (providerCacheRef && !cachedProvider) { + return { success: false, mode: 'provider-cache', error: '缓存中的 Claude provider 不存在,请重新同步' }; + } + const effectiveConfig = cachedProvider + ? { + ...config, + apiKey: cachedProvider.apiKey || config.apiKey || '', + baseUrl: cachedProvider.baseUrl || config.baseUrl || '', + model: cachedProvider.model || config.model || '', + targetApi: cachedProvider.targetApi || config.targetApi || 'responses' + } + : config; + const apiKey = (effectiveConfig.apiKey || '').trim(); + const targetApi = normalizeClaudeTargetApi(effectiveConfig.targetApi); if (!apiKey && targetApi !== 'ollama') { return { success: false, mode: 'settings-file', error: '请先输入 API Key' }; } - const configuredBaseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''; + const configuredBaseUrl = typeof effectiveConfig.baseUrl === 'string' ? effectiveConfig.baseUrl.trim() : ''; const baseUrl = (configuredBaseUrl || (targetApi === 'ollama' ? 'http://127.0.0.1:11434' : 'https://open.bigmodel.cn/api/anthropic')).trim(); - const model = (config.model || DEFAULT_CLAUDE_MODEL).trim(); + const model = (effectiveConfig.model || DEFAULT_CLAUDE_MODEL).trim(); let settingsBaseUrl = baseUrl; let settingsApiKey = apiKey; let proxyResult = null; if (targetApi === 'chat_completions' || targetApi === 'ollama') { - const upstreamProviderName = typeof config.name === 'string' ? config.name.trim() : ''; + const upstreamProviderName = typeof effectiveConfig.name === 'string' ? effectiveConfig.name.trim() : ''; if (targetApi === 'chat_completions' && !configuredBaseUrl && !upstreamProviderName) { return { success: false, @@ -12338,6 +12381,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'get-provider-cache-records': result = readProviderCacheRecords(); break; + case 'get-claude-provider-cache-configs': + result = readClaudeProviderCacheConfigs(); + break; case 'sync-provider-cache-records': result = syncProviderCacheRecords(); break; diff --git a/package-lock.json b/package-lock.json index 27be97cc..00705df7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codexmate", - "version": "0.0.54", + "version": "0.0.55", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexmate", - "version": "0.0.54", + "version": "0.0.55", "license": "Apache-2.0", "dependencies": { "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index d10c618b..939bde63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexmate", - "version": "0.0.54", + "version": "0.0.55", "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具", "main": "cli.js", "bin": { diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs index bef7bcea..b51d425c 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -12,6 +12,9 @@ const { createI18nMethods } = 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 { isLikelyBuiltinClaudeProxySettingsEnv, matchBuiltinClaudeProxyConfigFromSettings @@ -1097,6 +1100,42 @@ test('mergeClaudeConfig preserves externalCredentialType across edits without ap }); }); +test('mergeClaudeConfig preserves providerCacheRef and marks cached providers as key-backed', () => { + const source = extractMethodAsFunction(appSource, 'mergeClaudeConfig'); + const mergeClaudeConfig = instantiateFunction(source, 'mergeClaudeConfig'); + const context = { + normalizeClaudeConfig: (config = {}) => ({ + apiKey: typeof config.apiKey === 'string' ? config.apiKey.trim() : '', + baseUrl: typeof config.baseUrl === 'string' ? config.baseUrl.trim() : '', + model: typeof config.model === 'string' ? config.model.trim() : '', + authToken: typeof config.authToken === 'string' ? config.authToken.trim() : '', + useKey: typeof config.useKey === 'string' ? config.useKey.trim() : '', + externalCredentialType: typeof config.externalCredentialType === 'string' ? config.externalCredentialType.trim() : '', + targetApi: typeof config.targetApi === 'string' ? config.targetApi.trim() : 'responses' + }) + }; + + const merged = mergeClaudeConfig.call(context, {}, { + apiKey: '', + baseUrl: 'https://cached.example.com/anthropic', + model: 'claude-sonnet-4-6', + providerCacheRef: 'cached-provider', + source: 'provider-cache', + targetApi: 'responses' + }); + + assert.deepStrictEqual(merged, { + apiKey: '', + baseUrl: 'https://cached.example.com/anthropic', + model: 'claude-sonnet-4-6', + hasKey: true, + externalCredentialType: '', + targetApi: 'responses', + providerCacheRef: 'cached-provider', + source: 'provider-cache' + }); +}); + test('refreshClaudeSelectionFromSettings forwards silent model-error flag', async () => { const source = extractMethodAsFunction(appSource, 'refreshClaudeSelectionFromSettings'); const refreshClaudeSelectionFromSettings = instantiateFunction(source, 'refreshClaudeSelectionFromSettings', { @@ -1404,18 +1443,153 @@ test('loadClaudeModels skips remote fetch for external-credential config without assert.deepStrictEqual(messages, []); }); +test('hydrateClaudeConfigsFromProviderCache restores Claude providers without storing secrets', async () => { + const previousLocalStorage = globalThis.localStorage; + const stored = new Map(); + 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 apiCalls = []; + const methods = createClaudeConfigMethods({ + api: async (action) => { + apiCalls.push(action); + if (action === 'get-claude-provider-cache-configs') { + return { + providers: [{ + name: 'alpha-sync', + baseUrl: 'https://alpha.example.com/anthropic', + model: 'claude-sonnet-4-6', + targetApi: 'responses', + hasKey: true, + providerCacheRef: 'alpha-sync', + source: 'provider-cache' + }] + }; + } + return { success: true }; + } + }); + const context = { + ...methods, + claudeConfigs: { + '智谱GLM': { + apiKey: '', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + model: 'glm-4.7', + targetApi: 'responses', + hasKey: false + } + }, + currentClaudeConfig: '智谱GLM', + showMessage() { throw new Error('should stay silent'); }, + t(key) { return key; } + }; + + const ok = await context.hydrateClaudeConfigsFromProviderCache({ silent: true }); + + assert.strictEqual(ok, true); + assert.strictEqual(context.currentClaudeConfig, 'alpha-sync'); + assert.deepStrictEqual(context.claudeConfigs['alpha-sync'], { + apiKey: '', + baseUrl: 'https://alpha.example.com/anthropic', + model: 'claude-sonnet-4-6', + hasKey: true, + providerCacheRef: 'alpha-sync', + source: 'provider-cache', + targetApi: 'responses' + }); + assert.doesNotMatch(stored.get('claudeConfigs') || '', /sk-secret/); + assert.match(stored.get('claudeConfigs') || '', /providerCacheRef/); + assert(apiCalls.includes('get-claude-provider-cache-configs')); + } finally { + globalThis.localStorage = previousLocalStorage; + } +}); + +test('applyClaudeConfig accepts provider-cache backed Claude providers without browser api key', async () => { + const previousLocalStorage = globalThis.localStorage; + const stored = new Map(); + 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 apiCalls = []; + const messages = []; + const methods = createClaudeConfigMethods({ + api: async (action, params) => { + apiCalls.push({ action, params }); + return { success: true }; + } + }); + const context = { + ...methods, + claudeConfigs: { + cached: { + apiKey: '', + baseUrl: 'https://cached.example.com/anthropic', + model: 'claude-sonnet-4-6', + providerCacheRef: 'cached', + source: 'provider-cache', + hasKey: true, + targetApi: 'responses' + } + }, + currentClaudeConfig: '', + refreshClaudeModelContext() {}, + showMessage: (msg, type) => messages.push({ msg, type }), + t(key) { return key; } + }; + + await context.applyClaudeConfig('cached'); + + const applyCall = apiCalls.find((call) => call.action === 'apply-claude-config'); + assert(applyCall, 'apply-claude-config should be called'); + assert.strictEqual(applyCall.params.config.providerCacheRef, 'cached'); + assert.strictEqual(applyCall.params.config.apiKey, ''); + assert.deepStrictEqual(messages, [{ msg: 'toast.apply.success', type: 'success' }]); + } finally { + globalThis.localStorage = previousLocalStorage; + } +}); + test('applyToClaudeSettings does not proxy chat completions through default Anthropic URL', () => { const startIndex = cliSource.indexOf('async function applyToClaudeSettings'); assert.notStrictEqual(startIndex, -1); const endIndex = cliSource.indexOf('async function cmdClaude', startIndex); assert.notStrictEqual(endIndex, -1); const source = cliSource.slice(startIndex, endIndex); - assert.match(source, /const configuredBaseUrl = typeof config\.baseUrl === 'string' \? config\.baseUrl\.trim\(\) : '';/); + assert.match(source, /const configuredBaseUrl = typeof effectiveConfig\.baseUrl === 'string' \? effectiveConfig\.baseUrl\.trim\(\) : '';/); assert.match(source, /targetApi === 'chat_completions' && !configuredBaseUrl && !upstreamProviderName/); assert.match(source, /chat_completions 模式需要显式的上游 Base URL 或可解析的 provider 名称/); assert.match(source, /\.\.\.\(configuredBaseUrl \? \{ upstreamBaseUrl: configuredBaseUrl \} : \{\}\)/); }); +test('applyToClaudeSettings resolves Claude provider-cache references server-side', () => { + const startIndex = cliSource.indexOf('async function applyToClaudeSettings'); + assert.notStrictEqual(startIndex, -1); + const endIndex = cliSource.indexOf('function readClaudeSettingsRaw', startIndex); + assert.notStrictEqual(endIndex, -1); + const source = cliSource.slice(startIndex, endIndex); + assert.match(source, /const providerCacheRef = typeof config\.providerCacheRef === 'string'/); + assert.match(source, /readClaudeProviderCacheProvider\(providerCacheRef\)/); + assert.match(source, /apiKey: cachedProvider\.apiKey \|\| config\.apiKey \|\| ''/); + assert.match(source, /缓存中的 Claude provider 不存在,请重新同步/); +}); + +test('Claude provider cache catalog route exposes safe provider metadata only', () => { + const fn = extractFunctionDeclaration(cliSource, 'readClaudeProviderCacheConfigs'); + assert.match(fn, /providerCacheRef: name/); + assert.match(fn, /source: 'provider-cache'/); + assert.match(fn, /hasKey: typeof entry\.apiKey === 'string'/); + assert.doesNotMatch(fn, /apiKey:/); + assert.match(cliSource, /case 'get-claude-provider-cache-configs':/); +}); + test('MCP Claude config schema allows Ollama without API key only for ollama target', () => { const toolIndex = cliSource.indexOf("name: 'codexmate.claude.config.apply'"); assert.notStrictEqual(toolIndex, -1); diff --git a/tests/unit/provider-cache-records.test.mjs b/tests/unit/provider-cache-records.test.mjs index ff28a9fa..fb0a4c0b 100644 --- a/tests/unit/provider-cache-records.test.mjs +++ b/tests/unit/provider-cache-records.test.mjs @@ -229,6 +229,64 @@ test('provider cache sync method localizes backend error keys', async () => { assert.strictEqual(context.providerCacheSyncing, false); }); +test('provider cache background load hydrates records without flipping loading state', async () => { + const methods = createProviderCacheMethods({ + api: async () => ({ root: '~/.codexmate', generatedAt: 'background-time', groups: [] }) + }); + const context = { + providerCacheRecords: {}, + providerCacheLoadedOnce: false, + providerCacheLoadedAt: '', + providerCacheLoading: false, + providerCacheRequestSeq: 0, + providerCacheError: '', + t(key) { return key; }, + ...methods + }; + + await context.loadProviderCacheRecords({ background: true }); + + assert.strictEqual(context.providerCacheLoadedOnce, true); + assert.strictEqual(context.providerCacheLoadedAt, 'background-time'); + assert.strictEqual(context.providerCacheLoading, false); +}); + +test('provider cache force refresh ignores stale in-flight loads', async () => { + let resolveFirst; + const firstLoad = new Promise((resolve) => { + resolveFirst = resolve; + }); + const calls = []; + const methods = createProviderCacheMethods({ + api: async (action) => { + calls.push(action); + if (calls.length === 1) return firstLoad; + return { root: '~/.codexmate', generatedAt: 'fresh-time', groups: [{ key: 'codex', files: [] }] }; + } + }); + const context = { + providerCacheRecords: {}, + providerCacheLoadedOnce: false, + providerCacheLoadedAt: '', + providerCacheLoading: false, + providerCacheRequestSeq: 0, + providerCacheError: '', + t(key) { return key; }, + ...methods + }; + + const stalePromise = context.loadProviderCacheRecords(); + const freshPromise = context.loadProviderCacheRecords({ forceRefresh: true }); + await freshPromise; + resolveFirst({ root: '~/.codexmate', generatedAt: 'stale-time', groups: [] }); + await stalePromise; + + assert.deepStrictEqual(calls, ['get-provider-cache-records', 'get-provider-cache-records']); + assert.strictEqual(context.providerCacheLoadedAt, 'fresh-time'); + assert.strictEqual(context.providerCacheLoading, false); + assert.deepStrictEqual(context.providerCacheRecords.groups, [{ key: 'codex', files: [] }]); +}); + test('provider cache load fallback uses localized error text', async () => { const methods = createProviderCacheMethods({ api: async () => { @@ -272,6 +330,8 @@ test('provider cache UI template renders provider cards and collapsible raw JSON assert.match(html, /syncProviderCacheRecords/); assert.match(html, /modal\.providerCache\.sync/); assert.match(readProjectFile('web-ui/partials/index/panel-settings.html'), /settings\.providerCache\.sync/); + assert.match(readProjectFile('web-ui/app.js'), /providerCacheRequestSeq: 0/); + assert.match(readProjectFile('web-ui/app.js'), /loadProviderCacheRecords\(\{ background: true \}\)/); assert.doesNotMatch(html, /v-else-if="providerCacheSyncMessage"/); assert.match(html, /\(provider, providerIndex\) in getProviderCacheFileProviders\(file\)/); assert.match(html, /getProviderCacheFileKey\(file\) \+ ':' \+ providerIndex/); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index ecc8f883..cb246117 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -522,6 +522,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'providerCacheLoadedOnce', 'providerCacheLoading', 'providerCacheRecords', + 'providerCacheRequestSeq', 'providerCacheSyncing', 'providerCacheSyncMessage', 'showProviderCacheModal', @@ -767,6 +768,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'getProviderCacheProviderText', 'getProviderCacheRecordText', 'buildWebUiPreferencesSnapshot', + 'hydrateClaudeConfigsFromProviderCache', 'applyWebUiPreferences', 'loadWebUiPreferences', 'persistWebUiPreferences' diff --git a/tests/unit/web-ui-logic.test.mjs b/tests/unit/web-ui-logic.test.mjs index a56dafd3..489a5b6e 100644 --- a/tests/unit/web-ui-logic.test.mjs +++ b/tests/unit/web-ui-logic.test.mjs @@ -188,6 +188,21 @@ test('matchClaudeConfigFromSettings tolerates trailing slash differences', () => assert.strictEqual(matchClaudeConfigFromSettings(configs, env), 'default'); }); +test('matchClaudeConfigFromSettings matches provider-cache backed config without importing secrets', () => { + const configs = { + cached: { + apiKey: '', + baseUrl: 'https://example.com/anthropic', + model: 'm', + providerCacheRef: 'cached', + hasKey: true, + source: 'provider-cache' + } + }; + const env = { ANTHROPIC_API_KEY: 'sk-secret-from-settings', ANTHROPIC_BASE_URL: 'https://example.com/anthropic', ANTHROPIC_MODEL: 'm' }; + assert.strictEqual(matchClaudeConfigFromSettings(configs, env), 'cached'); +}); + test('matchClaudeConfigFromSettings matches external token-backed config by baseUrl and model', () => { const configs = { imported: { apiKey: '', baseUrl: 'https://example.com/anthropic/', model: 'm', externalCredentialType: 'auth-token' } }; const env = { ANTHROPIC_AUTH_TOKEN: 'token', ANTHROPIC_BASE_URL: 'https://example.com/anthropic', ANTHROPIC_MODEL: 'm' }; diff --git a/web-ui/app.js b/web-ui/app.js index 1481104a..fdaa4aa1 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -387,6 +387,7 @@ document.addEventListener('DOMContentLoaded', () => { providerCacheSyncing: false, providerCacheSyncMessage: '', providerCacheError: '', + providerCacheRequestSeq: 0, settingsTab: 'general', toolConfigPermissions: (function() { try { @@ -714,8 +715,14 @@ document.addEventListener('DOMContentLoaded', () => { if (typeof this.loadAppVersionStatus === 'function') { void this.loadAppVersionStatus({ silent: true }); } + if (typeof this.hydrateClaudeConfigsFromProviderCache === 'function') { + await this.hydrateClaudeConfigsFromProviderCache({ silent: true }); + } void this.refreshClaudeSelectionFromSettings({ silent: true }); void this.syncDefaultOpenclawConfigEntry({ silent: true }); + if (typeof this.loadProviderCacheRecords === 'function') { + void this.loadProviderCacheRecords({ background: true }); + } }; if (typeof requestAnimationFrame === 'function') { this._initialLoadRafId = requestAnimationFrame(() => { diff --git a/web-ui/logic.claude.mjs b/web-ui/logic.claude.mjs index 4a1d5c5d..542a011f 100644 --- a/web-ui/logic.claude.mjs +++ b/web-ui/logic.claude.mjs @@ -191,6 +191,14 @@ export function matchClaudeConfigFromSettings(claudeConfigs = {}, env = {}) { if (normalizedSettings.apiKey && normalizedConfig.apiKey === normalizedSettings.apiKey) { return name; } + if (normalizedSettings.apiKey + && normalizedConfig.apiKey === '' + && config + && typeof config.providerCacheRef === 'string' + && config.providerCacheRef.trim() + && config.hasKey === true) { + return name; + } if (!normalizedSettings.apiKey && normalizedConfig.apiKey === '' && normalizedConfig.externalCredentialType diff --git a/web-ui/modules/app.methods.claude-config.mjs b/web-ui/modules/app.methods.claude-config.mjs index 5fe75607..688e18e8 100644 --- a/web-ui/modules/app.methods.claude-config.mjs +++ b/web-ui/modules/app.methods.claude-config.mjs @@ -20,6 +20,7 @@ function getClaudeConfigValidationForContext(vm, mode = 'add') { const draft = mode === 'edit' ? vm.editingConfig : vm.newClaudeConfig; const name = normalizeClaudeText(draft && draft.name); const apiKey = normalizeClaudeText(draft && draft.apiKey); + const providerCacheRef = normalizeClaudeText(draft && draft.providerCacheRef); const externalCredentialType = normalizeClaudeText(draft && draft.externalCredentialType); const baseUrl = normalizeClaudeBaseUrl(draft && draft.baseUrl); const model = normalizeClaudeText(draft && draft.model); @@ -40,7 +41,7 @@ function getClaudeConfigValidationForContext(vm, mode = 'add') { errors.name = vm.t('validation.claude.nameExists'); } - if (!apiKey && !externalCredentialType && targetApi !== 'ollama') { + if (!apiKey && !externalCredentialType && !providerCacheRef && targetApi !== 'ollama') { errors.apiKey = vm.t('validation.claude.apiKeyRequired'); } @@ -58,6 +59,7 @@ function getClaudeConfigValidationForContext(vm, mode = 'add') { mode, name, apiKey, + providerCacheRef, externalCredentialType, baseUrl, model, @@ -112,6 +114,64 @@ export function createClaudeConfigMethods(options = {}) { this.syncClaudeBridgeProviders(); }, + async hydrateClaudeConfigsFromProviderCache(options = {}) { + const silent = options && options.silent === true; + try { + const res = await api('get-claude-provider-cache-configs'); + if (!res || res.error) { + if (!silent) this.showMessage((res && res.error) || this.t('toast.claude.loadSettingsFail'), 'error'); + return false; + } + const providers = Array.isArray(res.providers) ? res.providers : []; + if (providers.length === 0) return true; + const configs = this.claudeConfigs && typeof this.claudeConfigs === 'object' ? this.claudeConfigs : {}; + let changed = false; + let firstCachedName = ''; + for (const provider of providers) { + if (!provider || typeof provider !== 'object') continue; + const name = normalizeClaudeText(provider.name); + const baseUrl = normalizeClaudeBaseUrl(provider.baseUrl); + const model = normalizeClaudeText(provider.model); + if (!name || !baseUrl || !model) continue; + if (!firstCachedName) firstCachedName = name; + const cachedConfig = { + apiKey: '', + baseUrl, + model, + hasKey: provider.hasKey === true, + providerCacheRef: normalizeClaudeText(provider.providerCacheRef) || name, + source: 'provider-cache', + targetApi: normalizeClaudeText(provider.targetApi) || 'responses' + }; + const existing = configs[name]; + if (existing && existing.source !== 'provider-cache' && existing.providerCacheRef !== cachedConfig.providerCacheRef) { + continue; + } + if (JSON.stringify(existing || {}) !== JSON.stringify(cachedConfig)) { + configs[name] = cachedConfig; + changed = true; + } + } + this.claudeConfigs = configs; + if (firstCachedName) { + let savedCurrent = ''; + try { savedCurrent = localStorage.getItem('currentClaudeConfig') || ''; } catch (_) {} + const current = normalizeClaudeText(this.currentClaudeConfig); + const currentConfig = current && configs[current] ? configs[current] : null; + if (!savedCurrent && (!current || (currentConfig && currentConfig.hasKey === false && !currentConfig.providerCacheRef))) { + this.currentClaudeConfig = firstCachedName; + try { localStorage.setItem('currentClaudeConfig', firstCachedName); } catch (_) {} + changed = true; + } + } + if (changed) this.saveClaudeConfigs(); + return true; + } catch (e) { + if (!silent) this.showMessage(e && e.message ? e.message : this.t('toast.claude.loadSettingsFail'), 'error'); + return false; + } + }, + async syncClaudeBridgeProviders() { try { await api('claude-local-bridge-sync-providers', { providers: this.claudeConfigs || {} }); } catch (_) {} }, @@ -154,6 +214,7 @@ export function createClaudeConfigMethods(options = {}) { model: config.model || '', targetApi: config.targetApi || 'responses' }; + if (config.providerCacheRef) this.editingConfig.providerCacheRef = config.providerCacheRef; this.showEditClaudeConfigKey = false; this.showEditConfigModal = true; }, @@ -165,6 +226,8 @@ export function createClaudeConfigMethods(options = {}) { } const name = validation.name; this.editingConfig.apiKey = validation.apiKey; + if (validation.providerCacheRef) this.editingConfig.providerCacheRef = validation.providerCacheRef; + else delete this.editingConfig.providerCacheRef; this.editingConfig.externalCredentialType = validation.externalCredentialType; this.editingConfig.baseUrl = validation.baseUrl; this.editingConfig.model = validation.model; @@ -195,6 +258,8 @@ export function createClaudeConfigMethods(options = {}) { } const name = validation.name; this.editingConfig.apiKey = validation.apiKey; + if (validation.providerCacheRef) this.editingConfig.providerCacheRef = validation.providerCacheRef; + else delete this.editingConfig.providerCacheRef; this.editingConfig.externalCredentialType = validation.externalCredentialType; this.editingConfig.baseUrl = validation.baseUrl; this.editingConfig.model = validation.model; @@ -203,7 +268,7 @@ export function createClaudeConfigMethods(options = {}) { this.saveClaudeConfigs(); const config = this.claudeConfigs[name]; - if (!config.apiKey && config.targetApi !== 'ollama') { + if (!config.apiKey && !config.providerCacheRef && config.targetApi !== 'ollama') { this.showMessage(this.t('toast.claude.savedWithoutKey'), 'info'); this.closeEditConfigModal(); if (name === this.currentClaudeConfig) { @@ -212,7 +277,7 @@ export function createClaudeConfigMethods(options = {}) { return; } - const _claudeKey = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}|${config.targetApi || "responses"}`; + const _claudeKey = `${name}|${config.apiKey || ""}|${config.providerCacheRef || ""}|${config.baseUrl || ""}|${config.model || ""}|${config.targetApi || "responses"}`; try { const res = await api('apply-claude-config', { config: { ...config, name } }); if (res.error || res.success === false) { @@ -238,6 +303,8 @@ export function createClaudeConfigMethods(options = {}) { } this.newClaudeConfig.name = validation.name; this.newClaudeConfig.apiKey = validation.apiKey; + if (validation.providerCacheRef) this.newClaudeConfig.providerCacheRef = validation.providerCacheRef; + else delete this.newClaudeConfig.providerCacheRef; this.newClaudeConfig.externalCredentialType = validation.externalCredentialType; this.newClaudeConfig.baseUrl = validation.baseUrl; this.newClaudeConfig.model = validation.model; @@ -284,14 +351,14 @@ export function createClaudeConfigMethods(options = {}) { this.refreshClaudeModelContext(); const config = this.claudeConfigs[name]; - if (!config.apiKey && config.targetApi !== 'ollama') { + if (!config.apiKey && !config.providerCacheRef && config.targetApi !== 'ollama') { if (config.externalCredentialType) { return this.showMessage(this.t('toast.claude.externalAuth'), 'info'); } return this.showMessage(this.t('toast.claude.apiKeyRequired'), 'error'); } - const _claudeKey2 = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}|${config.targetApi || "responses"}`; + const _claudeKey2 = `${name}|${config.apiKey || ""}|${config.providerCacheRef || ""}|${config.baseUrl || ""}|${config.model || ""}|${config.targetApi || "responses"}`; try { const res = await api('apply-claude-config', { config: { ...config, name } }); if (res.error || res.success === false) { diff --git a/web-ui/modules/app.methods.provider-cache.mjs b/web-ui/modules/app.methods.provider-cache.mjs index bbb3a2aa..018f9d06 100644 --- a/web-ui/modules/app.methods.provider-cache.mjs +++ b/web-ui/modules/app.methods.provider-cache.mjs @@ -29,12 +29,20 @@ export function createProviderCacheMethods(options = {}) { await this.openProviderCacheModal({ forceRefresh: false }); }, - async loadProviderCacheRecords() { - if (this.providerCacheLoading) return; - this.providerCacheLoading = true; + async loadProviderCacheRecords(options = {}) { + const forceRefresh = options && options.forceRefresh === true; + const background = options && options.background === true; + if (this.providerCacheLoading && !forceRefresh) return; + const requestSeq = (Number(this.providerCacheRequestSeq) || 0) + 1; + this.providerCacheRequestSeq = requestSeq; + const isLatestRequest = () => requestSeq === Number(this.providerCacheRequestSeq || 0); + if (!background) { + this.providerCacheLoading = true; + } this.providerCacheError = ''; try { const res = await api('get-provider-cache-records'); + if (!isLatestRequest()) return; if (res && res.error) { this.providerCacheError = res.error; return; @@ -43,9 +51,12 @@ export function createProviderCacheMethods(options = {}) { this.providerCacheLoadedOnce = true; this.providerCacheLoadedAt = this.providerCacheRecords.generatedAt || new Date().toISOString(); } catch (e) { + if (!isLatestRequest()) return; this.providerCacheError = e && e.message ? e.message : this.t('modal.providerCache.loadFailed'); } finally { - this.providerCacheLoading = false; + if (isLatestRequest() && !background) { + this.providerCacheLoading = false; + } } }, @@ -69,7 +80,7 @@ export function createProviderCacheMethods(options = {}) { this.providerCacheLoadedOnce = true; this.providerCacheLoadedAt = this.providerCacheRecords.generatedAt || new Date().toISOString(); } - await this.loadProviderCacheRecords(); + await this.loadProviderCacheRecords({ forceRefresh: true }); } catch (e) { this.providerCacheError = e && e.message ? e.message : this.t('modal.providerCache.syncFailed'); } finally { diff --git a/web-ui/modules/app.methods.startup-claude.mjs b/web-ui/modules/app.methods.startup-claude.mjs index 7add9ecf..5722144d 100644 --- a/web-ui/modules/app.methods.startup-claude.mjs +++ b/web-ui/modules/app.methods.startup-claude.mjs @@ -260,17 +260,23 @@ export function createStartupClaudeMethods(options = {}) { mergeClaudeConfig(existing = {}, updates = {}) { const previous = this.normalizeClaudeConfig(existing); const next = this.normalizeClaudeConfig({ ...existing, ...updates }); + const raw = { ...existing, ...updates }; + const providerCacheRef = typeof raw.providerCacheRef === 'string' ? raw.providerCacheRef.trim() : ''; + const source = raw.source === 'provider-cache' ? 'provider-cache' : (existing.source === 'provider-cache' && providerCacheRef ? 'provider-cache' : ''); const externalCredentialType = next.apiKey ? '' : (next.externalCredentialType || previous.externalCredentialType || ''); - return { + const merged = { apiKey: next.apiKey, baseUrl: next.baseUrl, model: next.model || previous.model || 'glm-4.7', - hasKey: !!(next.apiKey || externalCredentialType), + hasKey: !!(next.apiKey || externalCredentialType || providerCacheRef || raw.hasKey === true), externalCredentialType, targetApi: next.targetApi || previous.targetApi || 'responses' }; + if (providerCacheRef) merged.providerCacheRef = providerCacheRef; + if (source) merged.source = source; + return merged; }, buildClaudeImportedConfigName(baseUrl) { @@ -459,6 +465,9 @@ export function createStartupClaudeMethods(options = {}) { const currentConfigName = typeof this.currentClaudeConfig === 'string' ? this.currentClaudeConfig.trim() : ''; const baseUrl = (config.baseUrl || '').trim(); const apiKey = (config.apiKey || '').trim(); + const providerCacheRef = typeof config.providerCacheRef === 'string' + ? config.providerCacheRef.trim() + : ''; const externalCredentialType = typeof config.externalCredentialType === 'string' ? config.externalCredentialType.trim() : ''; @@ -468,7 +477,7 @@ export function createStartupClaudeMethods(options = {}) { return; } const localCatalog = getClaudeModelCatalogForBaseUrl(baseUrl); - if (!apiKey && externalCredentialType) { + if (!apiKey && (externalCredentialType || providerCacheRef)) { this.claudeModels = localCatalog; this.claudeModelsSource = localCatalog.length ? 'catalog' : 'unlimited'; if (localCatalog.length) { @@ -520,6 +529,7 @@ export function createStartupClaudeMethods(options = {}) { } return (latestConfig.baseUrl || '').trim() === baseUrl && (latestConfig.apiKey || '').trim() === apiKey + && (typeof latestConfig.providerCacheRef === 'string' ? latestConfig.providerCacheRef.trim() : '') === providerCacheRef && (typeof latestConfig.externalCredentialType === 'string' ? latestConfig.externalCredentialType.trim() : '') === externalCredentialType; }; if (cachedOk) {