diff --git a/README.ja-JP.md b/README.ja-JP.md index e258333fc..beec9d87d 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -124,6 +124,7 @@ Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、 ### 🔐 セキュアなプロバイダー統合 複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。 OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。 +互換ゲートウェイで `/models` が認証以外の理由で使えない場合、ClawX は API キー検証時に軽量な `/chat/completions` または `/responses` プローブへ自動フォールバックします。 ### 🌙 アダプティブテーマ ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。 diff --git a/README.md b/README.md index de9a1f44b..c4118a7ef 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ Environment variables for bundled search skills: ### 🔐 Secure Provider Integration Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in. For **Custom** providers used with OpenAI-compatible gateways, you can set a custom `User-Agent` in **Settings → AI Providers → Edit Provider** for compatibility-sensitive endpoints. +When a compatible gateway rejects `/models` for non-auth reasons, ClawX automatically falls back to a lightweight `/chat/completions` or `/responses` probe during API key validation. ### 🌙 Adaptive Theming Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically. diff --git a/README.zh-CN.md b/README.zh-CN.md index dfb357331..c304ea87b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -125,6 +125,7 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、wor ### 🔐 安全的供应商集成 连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。 如果你通过 **自定义(Custom)Provider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。 +如果兼容网关的 `/models` 因非鉴权原因不可用,ClawX 会在校验 API Key 时自动降级为轻量的 `/chat/completions` 或 `/responses` 探测。 ### 🌙 自适应主题 支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。 diff --git a/electron/services/providers/provider-validation.ts b/electron/services/providers/provider-validation.ts index b18057cd4..b880e2277 100644 --- a/electron/services/providers/provider-validation.ts +++ b/electron/services/providers/provider-validation.ts @@ -10,6 +10,10 @@ type ValidationProfile = | 'none'; type ValidationResult = { valid: boolean; error?: string; status?: number }; +type ClassifiedValidationResult = ValidationResult & { authFailure?: boolean }; + +const AUTH_ERROR_PATTERN = /\b(unauthorized|forbidden|access denied|invalid api key|api key invalid|incorrect api key|api key incorrect|authentication failed|auth failed|invalid credential|credential invalid|invalid signature|signature invalid|invalid access token|access token invalid|invalid bearer token|bearer token invalid|access token expired)\b|鉴权失败|認証失敗|认证失败|無效密鑰|无效密钥|密钥无效|密鑰無效|憑證無效|凭证无效/i; +const AUTH_ERROR_CODE_PATTERN = /\b(unauthorized|forbidden|access[_-]?denied|invalid[_-]?api[_-]?key|api[_-]?key[_-]?invalid|incorrect[_-]?api[_-]?key|api[_-]?key[_-]?incorrect|authentication[_-]?failed|auth[_-]?failed|invalid[_-]?credential|credential[_-]?invalid|invalid[_-]?signature|signature[_-]?invalid|invalid[_-]?access[_-]?token|access[_-]?token[_-]?invalid|invalid[_-]?bearer[_-]?token|bearer[_-]?token[_-]?invalid|access[_-]?token[_-]?expired|invalid[_-]?token|token[_-]?invalid|token[_-]?expired)\b/i; function logValidationStatus(provider: string, status: number): void { console.log(`[clawx-validate] ${provider} HTTP ${status}`); @@ -118,7 +122,7 @@ async function performProviderValidationRequest( providerLabel: string, url: string, headers: Record, -): Promise { +): Promise { try { logValidationRequest(providerLabel, 'GET', url, headers); const response = await proxyAwareFetch(url, { headers }); @@ -137,16 +141,56 @@ async function performProviderValidationRequest( function classifyAuthResponse( status: number, data: unknown, -): { valid: boolean; error?: string } { +) : ClassifiedValidationResult { + const obj = data as { + error?: { message?: string; code?: string }; + message?: string; + code?: string; + } | null; + const msg = obj?.error?.message || obj?.message || `API error: ${status}`; + const code = obj?.error?.code || obj?.code; + const hasAuthCode = typeof code === 'string' && AUTH_ERROR_CODE_PATTERN.test(code); + if (status >= 200 && status < 300) return { valid: true }; if (status === 429) return { valid: true }; - if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' }; + if (status === 401 || status === 403) { + return { valid: false, error: 'Invalid API key', authFailure: true }; + } + if (status === 400 && (AUTH_ERROR_PATTERN.test(msg) || hasAuthCode)) { + const error = hasAuthCode && msg === `API error: ${status}` + ? `Invalid API key (${code})` + : msg || 'Invalid API key'; + return { valid: false, error, authFailure: true }; + } - const obj = data as { error?: { message?: string }; message?: string } | null; - const msg = obj?.error?.message || obj?.message || `API error: ${status}`; return { valid: false, error: msg }; } +function shouldFallbackFromModelsProbe(result: ClassifiedValidationResult): boolean { + if (result.valid || result.status === undefined) return false; + if (result.status === 401 || result.status === 403) return false; + if (result.authFailure) return false; + return true; +} + +function classifyProbeResponse( + status: number, + data: unknown, +): ClassifiedValidationResult { + const classified = classifyAuthResponse(status, data); + + if (status >= 200 && status < 300) { + return { valid: true, status }; + } + if (status === 429) { + return { valid: true, status }; + } + if (status === 400 && !classified.authFailure) { + return { valid: true, status }; + } + return { ...classified, status }; +} + async function validateOpenAiCompatibleKey( providerType: string, apiKey: string, @@ -162,9 +206,9 @@ async function validateOpenAiCompatibleKey( const { modelsUrl, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol); const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers); - if (modelsResult.status === 404) { + if (shouldFallbackFromModelsProbe(modelsResult)) { console.log( - `[clawx-validate] ${providerType} /models returned 404, falling back to ${apiProtocol} probe`, + `[clawx-validate] ${providerType} /models returned ${modelsResult.status}, falling back to ${apiProtocol} probe`, ); if (apiProtocol === 'openai-responses') { return await performResponsesProbe(providerType, probeUrl, headers); @@ -192,18 +236,7 @@ async function performResponsesProbe( }); logValidationStatus(providerLabel, response.status); const data = await response.json().catch(() => ({})); - - if (response.status === 401 || response.status === 403) { - return { valid: false, error: 'Invalid API key' }; - } - if ( - (response.status >= 200 && response.status < 300) || - response.status === 400 || - response.status === 429 - ) { - return { valid: true }; - } - return classifyAuthResponse(response.status, data); + return classifyProbeResponse(response.status, data); } catch (error) { return { valid: false, @@ -230,18 +263,7 @@ async function performChatCompletionsProbe( }); logValidationStatus(providerLabel, response.status); const data = await response.json().catch(() => ({})); - - if (response.status === 401 || response.status === 403) { - return { valid: false, error: 'Invalid API key' }; - } - if ( - (response.status >= 200 && response.status < 300) || - response.status === 400 || - response.status === 429 - ) { - return { valid: true }; - } - return classifyAuthResponse(response.status, data); + return classifyProbeResponse(response.status, data); } catch (error) { return { valid: false, @@ -268,18 +290,7 @@ async function performAnthropicMessagesProbe( }); logValidationStatus(providerLabel, response.status); const data = await response.json().catch(() => ({})); - - if (response.status === 401 || response.status === 403) { - return { valid: false, error: 'Invalid API key' }; - } - if ( - (response.status >= 200 && response.status < 300) || - response.status === 400 || - response.status === 429 - ) { - return { valid: true }; - } - return classifyAuthResponse(response.status, data); + return classifyProbeResponse(response.status, data); } catch (error) { return { valid: false, diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index b3037996f..f44e2b6b1 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -788,23 +788,66 @@ function removeLegacyMoonshotProviderEntry( return false; } +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function removeLegacyMoonshotKimiSearchConfig(config: Record): boolean { + const tools = isPlainRecord(config.tools) ? config.tools : null; + const web = tools && isPlainRecord(tools.web) ? tools.web : null; + const search = web && isPlainRecord(web.search) ? web.search : null; + if (!search || !('kimi' in search)) return false; + + delete search.kimi; + if (Object.keys(search).length === 0) { + delete web.search; + } + if (Object.keys(web).length === 0) { + delete tools.web; + } + if (Object.keys(tools).length === 0) { + delete config.tools; + } + return true; +} + +function upsertMoonshotWebSearchConfig( + config: Record, + legacyKimi?: Record, +): void { + const plugins = isPlainRecord(config.plugins) + ? config.plugins + : (Array.isArray(config.plugins) ? { load: [...config.plugins] } : {}); + const entries = isPlainRecord(plugins.entries) ? plugins.entries : {}; + const moonshot = isPlainRecord(entries[OPENCLAW_PROVIDER_KEY_MOONSHOT]) + ? entries[OPENCLAW_PROVIDER_KEY_MOONSHOT] as Record + : {}; + const moonshotConfig = isPlainRecord(moonshot.config) ? moonshot.config as Record : {}; + const currentWebSearch = isPlainRecord(moonshotConfig.webSearch) + ? moonshotConfig.webSearch as Record + : {}; + + const nextWebSearch = { ...(legacyKimi || {}), ...currentWebSearch }; + delete nextWebSearch.apiKey; + nextWebSearch.baseUrl = 'https://api.moonshot.cn/v1'; + + moonshotConfig.webSearch = nextWebSearch; + moonshot.config = moonshotConfig; + entries[OPENCLAW_PROVIDER_KEY_MOONSHOT] = moonshot; + plugins.entries = entries; + config.plugins = plugins; +} + 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) - : {}; + const tools = isPlainRecord(config.tools) ? config.tools : null; + const web = tools && isPlainRecord(tools.web) ? tools.web : null; + const search = web && isPlainRecord(web.search) ? web.search : null; + const legacyKimi = search && isPlainRecord(search.kimi) ? search.kimi : undefined; - // 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; + upsertMoonshotWebSearchConfig(config, legacyKimi); + removeLegacyMoonshotKimiSearchConfig(config); } /** @@ -1369,24 +1412,43 @@ 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. + // OpenClaw moved moonshot web search config under + // plugins.entries.moonshot.config.webSearch. Migrate the old key and strip + // any inline apiKey so auth-profiles/env remain the single source of truth. 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; + const tools = isPlainRecord(config.tools) ? config.tools : null; + const web = tools && isPlainRecord(tools.web) ? tools.web : null; + const search = web && isPlainRecord(web.search) ? web.search : null; + const legacyKimi = search && isPlainRecord(search.kimi) ? search.kimi : undefined; + const hadInlineApiKey = Boolean(legacyKimi && 'apiKey' in legacyKimi); + const hadLegacyKimi = Boolean(legacyKimi); + + if (legacyKimi) { + upsertMoonshotWebSearchConfig(config, legacyKimi); + removeLegacyMoonshotKimiSearchConfig(config); modified = true; + console.log('[sanitize] Migrated legacy "tools.web.search.kimi" to "plugins.entries.moonshot.config.webSearch"'); + } else { + const plugins = isPlainRecord(config.plugins) ? config.plugins : null; + const entries = plugins && isPlainRecord(plugins.entries) ? plugins.entries : null; + const moonshot = entries && isPlainRecord(entries[OPENCLAW_PROVIDER_KEY_MOONSHOT]) + ? entries[OPENCLAW_PROVIDER_KEY_MOONSHOT] as Record + : null; + const moonshotConfig = moonshot && isPlainRecord(moonshot.config) ? moonshot.config as Record : null; + const webSearch = moonshotConfig && isPlainRecord(moonshotConfig.webSearch) + ? moonshotConfig.webSearch as Record + : null; + if (webSearch && 'apiKey' in webSearch) { + delete webSearch.apiKey; + moonshotConfig!.webSearch = webSearch; + modified = true; + } + } + if (hadInlineApiKey) { + console.log('[sanitize] Removing stale key "tools.web.search.kimi.apiKey" from openclaw.json'); + } else if (hadLegacyKimi) { + console.log('[sanitize] Removing legacy key "tools.web.search.kimi" from openclaw.json'); } } diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index 6a08d7d40..2cc09eb91 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -393,6 +393,96 @@ describe('sanitizeOpenClawConfig', () => { logSpy.mockRestore(); }); + + it('migrates legacy tools.web.search.kimi into moonshot plugin config', async () => { + await writeOpenClawJson({ + 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 { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth'); + await sanitizeOpenClawConfig(); + + const result = await readOpenClawJson(); + const tools = (result.tools as Record | undefined) || {}; + const web = (tools.web as Record | undefined) || {}; + const search = (web.search as Record | undefined) || {}; + const moonshot = ((((result.plugins as Record).entries as Record).moonshot as Record).config as Record).webSearch as Record; + + expect(search).not.toHaveProperty('kimi'); + expect(moonshot).not.toHaveProperty('apiKey'); + expect(moonshot.baseUrl).toBe('https://api.moonshot.cn/v1'); + }); +}); + +describe('syncProviderConfigToOpenClaw', () => { + beforeEach(async () => { + vi.resetModules(); + vi.restoreAllMocks(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('writes moonshot web search config to plugin config instead of tools.web.search.kimi', async () => { + await writeOpenClawJson({ + models: { + providers: {}, + }, + }); + + const { syncProviderConfigToOpenClaw } = await import('@electron/utils/openclaw-auth'); + + await syncProviderConfigToOpenClaw('moonshot', 'kimi-k2.5', { + baseUrl: 'https://api.moonshot.cn/v1', + api: 'openai-completions', + }); + + const result = await readOpenClawJson(); + const tools = (result.tools as Record | undefined) || {}; + const web = (tools.web as Record | undefined) || {}; + const search = (web.search as Record | undefined) || {}; + const moonshot = ((((result.plugins as Record).entries as Record).moonshot as Record).config as Record).webSearch as Record; + + expect(search).not.toHaveProperty('kimi'); + expect(moonshot.baseUrl).toBe('https://api.moonshot.cn/v1'); + }); + + it('preserves legacy plugins array by converting it into plugins.load during moonshot sync', async () => { + await writeOpenClawJson({ + plugins: ['/tmp/custom-plugin.js'], + models: { + providers: {}, + }, + }); + + const { syncProviderConfigToOpenClaw } = await import('@electron/utils/openclaw-auth'); + + await syncProviderConfigToOpenClaw('moonshot', 'kimi-k2.5', { + baseUrl: 'https://api.moonshot.cn/v1', + api: 'openai-completions', + }); + + const result = await readOpenClawJson(); + const plugins = result.plugins as Record; + const load = plugins.load as string[]; + const moonshot = (((plugins.entries as Record).moonshot as Record).config as Record).webSearch as Record; + + expect(load).toEqual(['/tmp/custom-plugin.js']); + expect(moonshot.baseUrl).toBe('https://api.moonshot.cn/v1'); + }); }); describe('auth-backed provider discovery', () => { diff --git a/tests/unit/provider-validation.test.ts b/tests/unit/provider-validation.test.ts index cce9c86ac..7e1ca5290 100644 --- a/tests/unit/provider-validation.test.ts +++ b/tests/unit/provider-validation.test.ts @@ -121,6 +121,193 @@ describe('validateApiKeyWithProvider', () => { ); }); + it('falls back to /chat/completions when /models returns a non-auth 405 error', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Method Not Allowed' } }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Unknown model' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-chat-fallback', { + baseUrl: 'https://chat.example.com/v1', + apiProtocol: 'openai-completions', + }); + + expect(result).toMatchObject({ valid: true }); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 1, + 'https://chat.example.com/v1/models?limit=1', + expect.anything(), + ); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 2, + 'https://chat.example.com/v1/chat/completions', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('does not mask auth-like 400 errors behind a fallback probe', async () => { + proxyAwareFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Invalid API key provided' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-bad-key', { + baseUrl: 'https://chat.example.com/v1', + apiProtocol: 'openai-completions', + }); + + expect(result).toMatchObject({ valid: false, error: 'Invalid API key provided', status: 400 }); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + }); + + it('treats incorrect api key wording as an auth failure', async () => { + proxyAwareFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Incorrect API key provided' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-bad-key-incorrect', { + baseUrl: 'https://chat.example.com/v1', + apiProtocol: 'openai-completions', + }); + + expect(result).toMatchObject({ valid: false, error: 'Incorrect API key provided', status: 400 }); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + }); + + it('treats auth-like error codes on /models as invalid without fallback', async () => { + proxyAwareFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Bad Request', code: 'invalid_api_key' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-bad-key-code', { + baseUrl: 'https://chat.example.com/v1', + apiProtocol: 'openai-completions', + }); + + expect(result).toMatchObject({ valid: false, error: 'Bad Request', status: 400 }); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + }); + + it('keeps non-auth invalid_request style 400 probe responses as valid', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Method Not Allowed' } }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'invalid_request_error: invalid model' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-invalid-model-ok', { + baseUrl: 'https://chat.example.com/v1', + apiProtocol: 'openai-completions', + }); + + expect(result).toMatchObject({ valid: true, status: 400 }); + }); + + it('treats auth-like error codes on probe responses as invalid after fallback', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Method Not Allowed' } }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Bad Request', code: 'invalid_api_key' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-bad-key-probe-code', { + baseUrl: 'https://responses.example.com/v1', + apiProtocol: 'openai-responses', + }); + + expect(result).toMatchObject({ valid: false, error: 'Bad Request', status: 400 }); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 2, + 'https://responses.example.com/v1/responses', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('keeps token-limit style 400 probe responses as valid', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Method Not Allowed' } }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'max tokens exceeded' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-token-limit-ok', { + baseUrl: 'https://responses.example.com/v1', + apiProtocol: 'openai-responses', + }); + + expect(result).toMatchObject({ valid: true, status: 400 }); + }); + + it('does not mask localized auth-like 400 errors behind a fallback probe', async () => { + proxyAwareFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: '无效密钥' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-bad-key-cn', { + baseUrl: 'https://chat.example.com/v1', + apiProtocol: 'openai-completions', + }); + + expect(result).toMatchObject({ valid: false, error: '无效密钥', status: 400 }); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + }); + it('does not duplicate endpoint suffix when baseUrl already points to /responses', async () => { proxyAwareFetch .mockResolvedValueOnce( @@ -154,4 +341,66 @@ describe('validateApiKeyWithProvider', () => { expect.anything(), ); }); + + it('falls back to /responses when /models returns a non-auth 400 error', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Bad Request' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Unknown model' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-response-fallback', { + baseUrl: 'https://responses.example.com/v1', + apiProtocol: 'openai-responses', + }); + + expect(result).toMatchObject({ valid: true }); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 2, + 'https://responses.example.com/v1/responses', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('treats localized auth-like 400 probe responses as invalid after fallback', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Method Not Allowed' } }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: '鉴权失败' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-response-cn-auth', { + baseUrl: 'https://responses.example.com/v1', + apiProtocol: 'openai-responses', + }); + + expect(result).toMatchObject({ valid: false, error: '鉴权失败', status: 400 }); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 2, + 'https://responses.example.com/v1/responses', + expect.objectContaining({ + method: 'POST', + }) + ); + }); }); diff --git a/tests/unit/sanitize-config.test.ts b/tests/unit/sanitize-config.test.ts index 906d18600..4aab34346 100644 --- a/tests/unit/sanitize-config.test.ts +++ b/tests/unit/sanitize-config.test.ts @@ -207,12 +207,36 @@ async function sanitizeConfig( 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) { + const plugins = Array.isArray(config.plugins) + ? { load: [...config.plugins] } + : ((config.plugins as Record | undefined) || {}); + const entries = (plugins.entries as Record | undefined) || {}; + const moonshot = (entries.moonshot as Record | undefined) || {}; + const moonshotConfig = (moonshot.config as Record | undefined) || {}; + const currentWebSearch = (moonshotConfig.webSearch as Record | undefined) || {}; + if (Object.keys(kimi).length > 0) { delete kimi.apiKey; - search.kimi = kimi; - web.search = search; - tools.web = web; - config.tools = tools; + moonshotConfig.webSearch = { ...kimi, ...currentWebSearch, baseUrl: 'https://api.moonshot.cn/v1' }; + moonshot.config = moonshotConfig; + entries.moonshot = moonshot; + plugins.entries = entries; + config.plugins = plugins; + delete search.kimi; + if (Object.keys(search).length === 0) { + delete web.search; + } else { + web.search = search; + } + if (Object.keys(web).length === 0) { + delete tools.web; + } else { + tools.web = web; + } + if (Object.keys(tools).length === 0) { + delete config.tools; + } else { + config.tools = tools; + } modified = true; } } @@ -388,7 +412,7 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => { expect(result.agents).toEqual({ defaults: { model: { primary: 'gpt-4' } } }); }); - it('removes tools.web.search.kimi.apiKey when moonshot provider exists', async () => { + it('migrates tools.web.search.kimi into plugins.entries.moonshot.config.webSearch when moonshot provider exists', async () => { await writeConfig({ models: { providers: { @@ -411,9 +435,44 @@ describe('sanitizeOpenClawConfig (blocklist approach)', () => { 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'); + const tools = (result.tools as Record | undefined) || {}; + const web = (tools.web as Record | undefined) || {}; + const search = (web.search as Record | undefined) || {}; + const moonshot = ((((result.plugins as Record).entries as Record).moonshot as Record).config as Record).webSearch as Record; + expect(search).not.toHaveProperty('kimi'); + expect(moonshot).not.toHaveProperty('apiKey'); + expect(moonshot.baseUrl).toBe('https://api.moonshot.cn/v1'); + }); + + it('preserves legacy plugins array while migrating moonshot web search config', async () => { + await writeConfig({ + plugins: ['/tmp/custom-plugin.js'], + models: { + providers: { + moonshot: { baseUrl: 'https://api.moonshot.cn/v1', api: 'openai-completions' }, + }, + }, + tools: { + web: { + search: { + kimi: { + baseUrl: 'https://api.moonshot.cn/v1', + }, + }, + }, + }, + }); + + const modified = await sanitizeConfig(configPath); + expect(modified).toBe(true); + + const result = await readConfig(); + const plugins = result.plugins as Record; + const load = plugins.load as string[]; + const moonshot = ((((result.plugins as Record).entries as Record).moonshot as Record).config as Record).webSearch as Record; + + expect(load).toEqual(['/tmp/custom-plugin.js']); + expect(moonshot.baseUrl).toBe('https://api.moonshot.cn/v1'); }); it('keeps tools.web.search.kimi.apiKey when moonshot provider is absent', async () => {