Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
176 changes: 175 additions & 1 deletion tests/unit/claude-settings-sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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);
Expand Down
60 changes: 60 additions & 0 deletions tests/unit/provider-cache-records.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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/);
Expand Down
Loading
Loading