diff --git a/README.md b/README.md index ad6dbe75d..266f9fb68 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,9 @@ When you launch ClawX for the first time, the **Setup Wizard** will guide you th 3. **Skill Bundles** – Select pre-configured skills for common use cases 4. **Verification** – Test your configuration before entering the main interface +> Note for Moonshot (Kimi): ClawX keeps Kimi web search enabled by default. +> When Moonshot is configured, ClawX also syncs Kimi web search to the China endpoint (`https://api.moonshot.cn/v1`) in OpenClaw config. + ### Proxy Settings ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, or channels such as Telegram need to reach the internet through a local proxy client. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5bd77d4c0..3ba17ce3e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -155,6 +155,9 @@ pnpm dev 3. **技能包** – 选择适用于常见场景的预配置技能 4. **验证** – 在进入主界面前测试你的配置 +> Moonshot(Kimi)说明:ClawX 默认保持开启 Kimi 的 web search。 +> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`)。 + ### 代理设置 ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。 diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 610d63fbf..59d99c572 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -16,7 +16,7 @@ import { } from '../utils/paths'; import { getAllSettings, getSetting } from '../utils/store'; import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage'; -import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry'; +import { getProviderEnvVars, getKeyableProviderTypes } from '../utils/provider-registry'; import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol'; import { logger } from '../utils/logger'; import { getUvMirrorEnv } from '../utils/uv-env'; @@ -181,6 +181,14 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict'; })(); `; +function injectMoonshotWebSearchEnv( + env: Record, + apiKey: string +): void { + // OpenClaw web_search(kimi) reads KIMI_API_KEY before provider-specific config. + env.KIMI_API_KEY = apiKey; +} + function ensureGatewayFetchPreload(): string { const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs'); try { writeFileSync(dest, GATEWAY_FETCH_PRELOAD_SOURCE, 'utf-8'); } catch { /* best-effort */ } @@ -1146,9 +1154,14 @@ export class GatewayManager extends EventEmitter { const defaultProviderType = defaultProvider?.type; const defaultProviderKey = await getApiKey(defaultProviderId); if (defaultProviderType && defaultProviderKey) { - const envVar = getProviderEnvVar(defaultProviderType); - if (envVar) { - providerEnv[envVar] = defaultProviderKey; + const envVars = getProviderEnvVars(defaultProviderType); + if (envVars.length > 0) { + for (const envVar of envVars) { + providerEnv[envVar] = defaultProviderKey; + } + if (defaultProviderType === 'moonshot') { + injectMoonshotWebSearchEnv(providerEnv, defaultProviderKey); + } loadedProviderKeyCount++; } } @@ -1161,9 +1174,14 @@ export class GatewayManager extends EventEmitter { try { const key = await getApiKey(providerType); if (key) { - const envVar = getProviderEnvVar(providerType); - if (envVar) { - providerEnv[envVar] = key; + const envVars = getProviderEnvVars(providerType); + if (envVars.length > 0) { + for (const envVar of envVars) { + providerEnv[envVar] = key; + } + if (providerType === 'moonshot') { + injectMoonshotWebSearchEnv(providerEnv, key); + } loadedProviderKeyCount++; } } diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index ced2a1b26..a690c8d7b 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -54,25 +54,27 @@ import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; import { applyProxySettings } from './proxy'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; +import { + getOpenClawProviderKeyForType, + getOAuthApiKeyEnv, + getOAuthProviderApi, + getOAuthProviderDefaultBaseUrl, + getOAuthProviderTargetKey, + isOAuthProviderType, + normalizeOAuthBaseUrl, + usesOAuthAuthHeader, +} from '../utils/provider-keys'; /** - * For custom/ollama providers, derive a unique key for OpenClaw config files - * so that multiple instances of the same type don't overwrite each other. - * For all other providers the key is simply the provider type. + * Derive OpenClaw provider key used in openclaw.json / models.json. + * Some types need remapping to avoid collisions or enforce CN endpoints. * * @param type - Provider type (e.g. 'custom', 'ollama', 'openrouter') * @param providerId - Unique provider ID from secure-storage (UUID-like) - * @returns A string like 'custom-a1b2c3d4' or 'openrouter' + * @returns A key like 'custom-a1b2c3d4', 'moonshot', or 'openrouter' */ export function getOpenClawProviderKey(type: string, providerId: string): string { - if (type === 'custom' || type === 'ollama') { - const suffix = providerId.replace(/-/g, '').slice(0, 8); - return `${type}-${suffix}`; - } - if (type === 'minimax-portal-cn') { - return 'minimax-portal'; - } - return type; + return getOpenClawProviderKeyForType(type, providerId); } function getProviderModelRef(config: ProviderConfig): string | undefined { @@ -84,7 +86,10 @@ function getProviderModelRef(config: ProviderConfig): string | undefined { : `${providerKey}/${config.model}`; } - return getProviderDefaultModel(config.type); + const defaultModel = getProviderDefaultModel(config.type); + if (!defaultModel) return undefined; + const modelId = defaultModel.includes('/') ? defaultModel.split('/').slice(1).join('/') : defaultModel; + return `${providerKey}/${modelId}`; } async function getProviderFallbackModelRefs(config: ProviderConfig): Promise { @@ -1201,16 +1206,20 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // If this provider is the current default, update the primary model const defaultProviderId = await getDefaultProvider(); if (defaultProviderId === providerId) { - const modelOverride = nextConfig.model - ? `${ock}/${nextConfig.model}` - : undefined; - if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); - } else { + const modelOverride = getProviderModelRef(nextConfig); + const providerKeyIsAliased = ock !== nextConfig.type; + if (nextConfig.type === 'custom' || nextConfig.type === 'ollama' || providerKeyIsAliased) { + const baseMeta = getProviderConfig(nextConfig.type); await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: nextConfig.baseUrl, - api: 'openai-completions', + baseUrl: nextConfig.baseUrl || baseMeta?.baseUrl, + api: nextConfig.type === 'custom' || nextConfig.type === 'ollama' + ? 'openai-completions' + : baseMeta?.api, + apiKeyEnv: baseMeta?.apiKeyEnv, + headers: baseMeta?.headers, }, fallbackModels); + } else { + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); } } @@ -1288,23 +1297,23 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const providerKey = await getApiKey(providerId); const fallbackModels = await getProviderFallbackModelRefs(provider); - // OAuth providers (qwen-portal, minimax-portal, minimax-portal-cn) might use OAuth OR a direct API key. - // Treat them as OAuth only if they don't have a local API key configured. - const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; - const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type) && !providerKey; + // OAuth providers might use OAuth OR a direct API key. + // Treat them as OAuth-only if they don't have a local API key configured. + const isOAuthProvider = isOAuthProviderType(provider.type) && !providerKey; if (!isOAuthProvider) { - // Build the full model string: "openclawKey/modelId" - const modelOverride = provider.model - ? (provider.model.startsWith(`${ock}/`) - ? provider.model - : `${ock}/${provider.model}`) - : undefined; - - if (provider.type === 'custom' || provider.type === 'ollama') { + // Build the model reference from provider settings/default mapping. + const modelOverride = getProviderModelRef(provider); + const providerKeyIsAliased = ock !== provider.type; + if (provider.type === 'custom' || provider.type === 'ollama' || providerKeyIsAliased) { + const baseMeta = getProviderConfig(provider.type); await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: provider.baseUrl, - api: 'openai-completions', + baseUrl: provider.baseUrl || baseMeta?.baseUrl, + api: provider.type === 'custom' || provider.type === 'ollama' + ? 'openai-completions' + : baseMeta?.api, + apiKeyEnv: baseMeta?.apiKeyEnv, + headers: baseMeta?.headers, }, fallbackModels); } else { await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); @@ -1315,32 +1324,22 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { await saveProviderKeyToOpenClaw(ock, providerKey); } } else { - // OAuth providers (minimax-portal, minimax-portal-cn, qwen-portal) - const defaultBaseUrl = provider.type === 'minimax-portal' - ? 'https://api.minimax.io/anthropic' - : (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1'); - const api: 'anthropic-messages' | 'openai-completions' = - (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') - ? 'anthropic-messages' - : 'openai-completions'; - - let baseUrl = provider.baseUrl || defaultBaseUrl; - if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) { - baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; + const defaultBaseUrl = getOAuthProviderDefaultBaseUrl(provider.type); + const api = getOAuthProviderApi(provider.type); + const targetProviderKey = getOAuthProviderTargetKey(provider.type); + const baseUrl = normalizeOAuthBaseUrl(provider.type, provider.baseUrl || defaultBaseUrl); + const oauthApiKeyEnv = targetProviderKey ? getOAuthApiKeyEnv(targetProviderKey) : undefined; + const oauthUsesAuthHeader = targetProviderKey ? usesOAuthAuthHeader(targetProviderKey) : false; + if (!baseUrl || !api || !targetProviderKey || !oauthApiKeyEnv) { + throw new Error(`Invalid OAuth provider config for "${provider.type}"`); } - // To ensure the OpenClaw Gateway's internal token refresher works, - // we must save the CN provider under the "minimax-portal" key in openclaw.json - const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') - ? 'minimax-portal' - : provider.type; - await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { baseUrl, api, - authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + authHeader: oauthUsesAuthHeader ? true : undefined, // Relies on OpenClaw Gateway native auth-profiles syncing - apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + apiKeyEnv: oauthApiKeyEnv, }, fallbackModels); logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`); @@ -1352,8 +1351,8 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { await updateAgentModelProvider(targetProviderKey, { baseUrl, api, - authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, - apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + authHeader: oauthUsesAuthHeader ? true : undefined, + apiKey: oauthApiKeyEnv, models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [], }); } catch (err) { @@ -2264,4 +2263,3 @@ function registerSessionHandlers(): void { } }); } - diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 3beabb5bc..6538e4c9f 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -17,6 +17,11 @@ import { getProviderDefaultModel, getProviderConfig, } from './provider-registry'; +import { + OPENCLAW_PROVIDER_KEY_MOONSHOT, + isOAuthProviderType, + isOpenClawOAuthPluginProviderKey, +} from './provider-keys'; const AUTH_STORE_VERSION = 1; const AUTH_PROFILE_FILENAME = 'auth-profiles.json'; @@ -207,8 +212,7 @@ export async function saveProviderKeyToOpenClaw( apiKey: string, agentId?: string ): Promise { - const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; - if (OAUTH_PROVIDERS.includes(provider) && !apiKey) { + if (isOAuthProviderType(provider) && !apiKey) { console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`); return; } @@ -242,8 +246,7 @@ export async function removeProviderKeyFromOpenClaw( provider: string, agentId?: string ): Promise { - const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; - if (OAUTH_PROVIDERS.includes(provider)) { + if (isOAuthProviderType(provider)) { console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`); return; } @@ -364,6 +367,7 @@ export async function setOpenClawDefaultModel( fallbackModels: string[] = [] ): Promise { const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const model = modelOverride || getProviderDefaultModel(provider); if (!model) { @@ -393,6 +397,7 @@ export async function setOpenClawDefaultModel( if (providerCfg) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; + const removedLegacyMoonshot = removeLegacyMoonshotProviderEntry(provider, providers); const existingProvider = providers[provider] && typeof providers[provider] === 'object' @@ -429,6 +434,9 @@ export async function setOpenClawDefaultModel( } providers[provider] = providerEntry; console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`); + if (removedLegacyMoonshot) { + console.log('Removed legacy models.providers.moonshot alias entry'); + } models.providers = providers; config.models = models; @@ -461,6 +469,32 @@ interface RuntimeProviderConfigOverride { authHeader?: boolean; } +function removeLegacyMoonshotProviderEntry( + _provider: string, + _providers: Record +): boolean { + return false; +} + +function ensureMoonshotKimiWebSearchCnBaseUrl(config: Record, provider: string): void { + if (provider !== OPENCLAW_PROVIDER_KEY_MOONSHOT) return; + + const tools = (config.tools || {}) as Record; + const web = (tools.web || {}) as Record; + const search = (web.search || {}) as Record; + const kimi = (search.kimi && typeof search.kimi === 'object' && !Array.isArray(search.kimi)) + ? (search.kimi as Record) + : {}; + + // Prefer env/auth-profiles for key resolution; stale inline kimi.apiKey can cause persistent 401. + delete kimi.apiKey; + kimi.baseUrl = 'https://api.moonshot.cn/v1'; + search.kimi = kimi; + web.search = search; + tools.web = web; + config.tools = tools; +} + /** * Register or update a provider's configuration in openclaw.json * without changing the current default model. @@ -471,10 +505,12 @@ export async function syncProviderConfigToOpenClaw( override: RuntimeProviderConfigOverride ): Promise { const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); if (override.baseUrl && override.api) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; + removeLegacyMoonshotProviderEntry(provider, providers); const nextModels: Array> = []; if (modelId) nextModels.push({ id: modelId, name: modelId }); @@ -495,7 +531,7 @@ export async function syncProviderConfigToOpenClaw( } // Ensure extension is enabled for oauth providers to prevent gateway wiping config - if (provider === 'minimax-portal' || provider === 'qwen-portal') { + if (isOpenClawOAuthPluginProviderKey(provider)) { const plugins = (config.plugins || {}) as Record; const pEntries = (plugins.entries || {}) as Record; pEntries[`${provider}-auth`] = { enabled: true }; @@ -516,6 +552,7 @@ export async function setOpenClawDefaultModelWithOverride( fallbackModels: string[] = [] ): Promise { const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const model = modelOverride || getProviderDefaultModel(provider); if (!model) { @@ -542,6 +579,7 @@ export async function setOpenClawDefaultModelWithOverride( if (override.baseUrl && override.api) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; + removeLegacyMoonshotProviderEntry(provider, providers); const nextModels: Array> = []; for (const candidateModelId of [modelId, ...fallbackModelIds]) { @@ -573,7 +611,7 @@ export async function setOpenClawDefaultModelWithOverride( config.gateway = gateway; // Ensure the extension plugin is marked as enabled in openclaw.json - if (provider === 'minimax-portal' || provider === 'qwen-portal') { + if (isOpenClawOAuthPluginProviderKey(provider)) { const plugins = (config.plugins || {}) as Record; const pEntries = (plugins.entries || {}) as Record; pEntries[`${provider}-auth`] = { enabled: true }; @@ -781,6 +819,28 @@ export async function sanitizeOpenClawConfig(): Promise { } } + // ── tools.web.search.kimi ───────────────────────────────────── + // OpenClaw web_search(kimi) prioritizes tools.web.search.kimi.apiKey over + // environment/auth-profiles. A stale inline key can cause persistent 401s. + // When ClawX-managed moonshot provider exists, prefer centralized key + // resolution and strip the inline key. + const providers = ((config.models as Record | undefined)?.providers as Record | undefined) || {}; + if (providers[OPENCLAW_PROVIDER_KEY_MOONSHOT]) { + const tools = (config.tools as Record | undefined) || {}; + const web = (tools.web as Record | undefined) || {}; + const search = (web.search as Record | undefined) || {}; + const kimi = (search.kimi as Record | undefined) || {}; + if ('apiKey' in kimi) { + console.log('[sanitize] Removing stale key "tools.web.search.kimi.apiKey" from openclaw.json'); + delete kimi.apiKey; + search.kimi = kimi; + web.search = search; + tools.web = web; + config.tools = tools; + modified = true; + } + } + if (modified) { await writeOpenClawJson(config); console.log('[sanitize] openclaw.json sanitized successfully'); diff --git a/electron/utils/provider-keys.ts b/electron/utils/provider-keys.ts new file mode 100644 index 000000000..7dac4414f --- /dev/null +++ b/electron/utils/provider-keys.ts @@ -0,0 +1,73 @@ +const MULTI_INSTANCE_PROVIDER_TYPES = new Set(['custom', 'ollama']); + +export const OPENCLAW_PROVIDER_KEY_MINIMAX = 'minimax-portal'; +export const OPENCLAW_PROVIDER_KEY_QWEN = 'qwen-portal'; +export const OPENCLAW_PROVIDER_KEY_MOONSHOT = 'moonshot'; +export const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'] as const; +export const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS = [ + OPENCLAW_PROVIDER_KEY_MINIMAX, + OPENCLAW_PROVIDER_KEY_QWEN, +] as const; + +const OAUTH_PROVIDER_TYPE_SET = new Set(OAUTH_PROVIDER_TYPES); +const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEY_SET = new Set(OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS); + +const PROVIDER_KEY_ALIASES: Record = { + 'minimax-portal-cn': OPENCLAW_PROVIDER_KEY_MINIMAX, +}; + +export function getOpenClawProviderKeyForType(type: string, providerId: string): string { + if (MULTI_INSTANCE_PROVIDER_TYPES.has(type)) { + const suffix = providerId.replace(/-/g, '').slice(0, 8); + return `${type}-${suffix}`; + } + + return PROVIDER_KEY_ALIASES[type] ?? type; +} + +export function isOAuthProviderType(type: string): boolean { + return OAUTH_PROVIDER_TYPE_SET.has(type); +} + +export function isMiniMaxProviderType(type: string): boolean { + return type === OPENCLAW_PROVIDER_KEY_MINIMAX || type === 'minimax-portal-cn'; +} + +export function getOAuthProviderTargetKey(type: string): string | undefined { + if (!isOAuthProviderType(type)) return undefined; + return isMiniMaxProviderType(type) ? OPENCLAW_PROVIDER_KEY_MINIMAX : OPENCLAW_PROVIDER_KEY_QWEN; +} + +export function getOAuthProviderApi(type: string): 'anthropic-messages' | 'openai-completions' | undefined { + if (!isOAuthProviderType(type)) return undefined; + return isMiniMaxProviderType(type) ? 'anthropic-messages' : 'openai-completions'; +} + +export function getOAuthProviderDefaultBaseUrl(type: string): string | undefined { + if (!isOAuthProviderType(type)) return undefined; + if (type === OPENCLAW_PROVIDER_KEY_MINIMAX) return 'https://api.minimax.io/anthropic'; + if (type === 'minimax-portal-cn') return 'https://api.minimaxi.com/anthropic'; + return 'https://portal.qwen.ai/v1'; +} + +export function normalizeOAuthBaseUrl(type: string, baseUrl?: string): string | undefined { + if (!baseUrl) return undefined; + if (isMiniMaxProviderType(type)) { + return baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; + } + return baseUrl; +} + +export function usesOAuthAuthHeader(providerKey: string): boolean { + return providerKey === OPENCLAW_PROVIDER_KEY_MINIMAX; +} + +export function getOAuthApiKeyEnv(providerKey: string): string | undefined { + if (providerKey === OPENCLAW_PROVIDER_KEY_MINIMAX) return 'minimax-oauth'; + if (providerKey === OPENCLAW_PROVIDER_KEY_QWEN) return 'qwen-oauth'; + return undefined; +} + +export function isOpenClawOAuthPluginProviderKey(provider: string): boolean { + return OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEY_SET.has(provider); +} diff --git a/electron/utils/provider-registry.ts b/electron/utils/provider-registry.ts index 1d4904d28..4ed73f1a9 100644 --- a/electron/utils/provider-registry.ts +++ b/electron/utils/provider-registry.ts @@ -154,6 +154,13 @@ export function getProviderEnvVar(type: string): string | undefined { return REGISTRY[type]?.envVar; } +/** Get all environment variable names for a provider type (primary first). */ +export function getProviderEnvVars(type: string): string[] { + const meta = REGISTRY[type]; + if (!meta?.envVar) return []; + return [meta.envVar]; +} + /** Get the default model string for a provider type */ export function getProviderDefaultModel(type: string): string | undefined { return REGISTRY[type]?.defaultModel; diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index 3b408473a..6452063d8 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -6,6 +6,7 @@ import { BUILTIN_PROVIDER_TYPES, type ProviderType } from './provider-registry'; import { getActiveOpenClawProviders } from './openclaw-auth'; +import { getOpenClawProviderKeyForType } from './provider-keys'; // Lazy-load electron-store (ESM module) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -228,9 +229,7 @@ export async function getAllProvidersWithKeyInfo(): Promise< // e.g. provider.id "custom-a1b2c3d4-..." → strip hyphens → "customa1b2c3d4..." → slice(0,8) → "customa1" // → openClawKey = "custom-customa1" // This must match getOpenClawProviderKey() in ipc-handlers.ts exactly. - const openClawKey = (provider.type === 'custom' || provider.type === 'ollama') - ? `${provider.type}-${provider.id.replace(/-/g, '').slice(0, 8)}` - : provider.type === 'minimax-portal-cn' ? 'minimax-portal' : provider.type; + const openClawKey = getOpenClawProviderKeyForType(provider.type, provider.id); if (!isBuiltin && !activeOpenClawProviders.has(provider.type) && !activeOpenClawProviders.has(provider.id) && !activeOpenClawProviders.has(openClawKey)) { console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, dropping from ClawX UI`); await deleteProvider(provider.id); diff --git a/tests/unit/providers.test.ts b/tests/unit/providers.test.ts index 633defad1..54c1b450e 100644 --- a/tests/unit/providers.test.ts +++ b/tests/unit/providers.test.ts @@ -10,6 +10,7 @@ import { BUILTIN_PROVIDER_TYPES, getProviderConfig, getProviderEnvVar, + getProviderEnvVars, } from '@electron/utils/provider-registry'; describe('provider metadata', () => { @@ -40,6 +41,17 @@ describe('provider metadata', () => { }); }); + it('uses a single canonical env key for moonshot provider', () => { + expect(getProviderEnvVar('moonshot')).toBe('MOONSHOT_API_KEY'); + expect(getProviderEnvVars('moonshot')).toEqual(['MOONSHOT_API_KEY']); + expect(getProviderConfig('moonshot')).toEqual( + expect.objectContaining({ + baseUrl: 'https://api.moonshot.cn/v1', + apiKeyEnv: 'MOONSHOT_API_KEY', + }) + ); + }); + it('keeps builtin provider sources in sync', () => { expect(BUILTIN_PROVIDER_TYPES).toEqual( expect.arrayContaining(['anthropic', 'openai', 'google', 'openrouter', 'ark', 'moonshot', 'siliconflow', 'minimax-portal', 'minimax-portal-cn', 'qwen-portal', 'ollama']) diff --git a/tests/unit/sanitize-config.test.ts b/tests/unit/sanitize-config.test.ts index 732b22603..21a0eb4ac 100644 --- a/tests/unit/sanitize-config.test.ts +++ b/tests/unit/sanitize-config.test.ts @@ -53,6 +53,23 @@ async function sanitizeConfig(filePath: string): Promise { } } + // Mirror: remove stale tools.web.search.kimi.apiKey when moonshot provider exists. + const providers = ((config.models as Record | undefined)?.providers as Record | undefined) || {}; + if (providers.moonshot) { + const tools = (config.tools as Record | undefined) || {}; + const web = (tools.web as Record | undefined) || {}; + const search = (web.search as Record | undefined) || {}; + const kimi = (search.kimi as Record | undefined) || {}; + if ('apiKey' in kimi) { + delete kimi.apiKey; + search.kimi = kimi; + web.search = search; + tools.web = web; + config.tools = tools; + modified = true; + } + } + if (modified) { await writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8'); } @@ -223,4 +240,58 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => { expect(result.gateway).toEqual({ mode: 'local', auth: { token: 'xyz' } }); expect(result.agents).toEqual({ defaults: { model: { primary: 'gpt-4' } } }); }); + + it('removes tools.web.search.kimi.apiKey when moonshot provider exists', async () => { + await writeConfig({ + models: { + providers: { + moonshot: { baseUrl: 'https://api.moonshot.cn/v1', api: 'openai-completions' }, + }, + }, + tools: { + web: { + search: { + kimi: { + apiKey: 'stale-inline-key', + baseUrl: 'https://api.moonshot.cn/v1', + }, + }, + }, + }, + }); + + const modified = await sanitizeConfig(configPath); + expect(modified).toBe(true); + + const result = await readConfig(); + const kimi = ((((result.tools as Record).web as Record).search as Record).kimi as Record); + expect(kimi).not.toHaveProperty('apiKey'); + expect(kimi.baseUrl).toBe('https://api.moonshot.cn/v1'); + }); + + it('keeps tools.web.search.kimi.apiKey when moonshot provider is absent', async () => { + const original = { + models: { + providers: { + openrouter: { baseUrl: 'https://openrouter.ai/api/v1', api: 'openai-completions' }, + }, + }, + tools: { + web: { + search: { + kimi: { + apiKey: 'should-stay', + }, + }, + }, + }, + }; + await writeConfig(original); + + const modified = await sanitizeConfig(configPath); + expect(modified).toBe(false); + + const result = await readConfig(); + expect(result).toEqual(original); + }); });