fix custom provider API key validation fallback (#773)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
e640316382
commit
91c735c9f4
@@ -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はあなたの好みに自動的に適応します。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 自动适应你的偏好设置。
|
||||
|
||||
@@ -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<string, string>,
|
||||
): Promise<ValidationResult> {
|
||||
): Promise<ClassifiedValidationResult> {
|
||||
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,
|
||||
|
||||
@@ -788,23 +788,66 @@ function removeLegacyMoonshotProviderEntry(
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function removeLegacyMoonshotKimiSearchConfig(config: Record<string, unknown>): 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<string, unknown>,
|
||||
legacyKimi?: Record<string, unknown>,
|
||||
): 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<string, unknown>
|
||||
: {};
|
||||
const moonshotConfig = isPlainRecord(moonshot.config) ? moonshot.config as Record<string, unknown> : {};
|
||||
const currentWebSearch = isPlainRecord(moonshotConfig.webSearch)
|
||||
? moonshotConfig.webSearch as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
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<string, unknown>, provider: string): void {
|
||||
if (provider !== OPENCLAW_PROVIDER_KEY_MOONSHOT) return;
|
||||
|
||||
const tools = (config.tools || {}) as Record<string, unknown>;
|
||||
const web = (tools.web || {}) as Record<string, unknown>;
|
||||
const search = (web.search || {}) as Record<string, unknown>;
|
||||
const kimi = (search.kimi && typeof search.kimi === 'object' && !Array.isArray(search.kimi))
|
||||
? (search.kimi as Record<string, unknown>)
|
||||
: {};
|
||||
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<void> {
|
||||
}
|
||||
|
||||
// ── 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<string, unknown> | undefined)?.providers as Record<string, unknown> | undefined) || {};
|
||||
if (providers[OPENCLAW_PROVIDER_KEY_MOONSHOT]) {
|
||||
const tools = (config.tools as Record<string, unknown> | undefined) || {};
|
||||
const web = (tools.web as Record<string, unknown> | undefined) || {};
|
||||
const search = (web.search as Record<string, unknown> | undefined) || {};
|
||||
const kimi = (search.kimi as Record<string, unknown> | 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<string, unknown>
|
||||
: null;
|
||||
const moonshotConfig = moonshot && isPlainRecord(moonshot.config) ? moonshot.config as Record<string, unknown> : null;
|
||||
const webSearch = moonshotConfig && isPlainRecord(moonshotConfig.webSearch)
|
||||
? moonshotConfig.webSearch as Record<string, unknown>
|
||||
: 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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> | undefined) || {};
|
||||
const web = (tools.web as Record<string, unknown> | undefined) || {};
|
||||
const search = (web.search as Record<string, unknown> | undefined) || {};
|
||||
const moonshot = ((((result.plugins as Record<string, unknown>).entries as Record<string, unknown>).moonshot as Record<string, unknown>).config as Record<string, unknown>).webSearch as Record<string, unknown>;
|
||||
|
||||
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<string, unknown> | undefined) || {};
|
||||
const web = (tools.web as Record<string, unknown> | undefined) || {};
|
||||
const search = (web.search as Record<string, unknown> | undefined) || {};
|
||||
const moonshot = ((((result.plugins as Record<string, unknown>).entries as Record<string, unknown>).moonshot as Record<string, unknown>).config as Record<string, unknown>).webSearch as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
const load = plugins.load as string[];
|
||||
const moonshot = (((plugins.entries as Record<string, unknown>).moonshot as Record<string, unknown>).config as Record<string, unknown>).webSearch as Record<string, unknown>;
|
||||
|
||||
expect(load).toEqual(['/tmp/custom-plugin.js']);
|
||||
expect(moonshot.baseUrl).toBe('https://api.moonshot.cn/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth-backed provider discovery', () => {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,12 +207,36 @@ async function sanitizeConfig(
|
||||
const web = (tools.web as Record<string, unknown> | undefined) || {};
|
||||
const search = (web.search as Record<string, unknown> | undefined) || {};
|
||||
const kimi = (search.kimi as Record<string, unknown> | undefined) || {};
|
||||
if ('apiKey' in kimi) {
|
||||
const plugins = Array.isArray(config.plugins)
|
||||
? { load: [...config.plugins] }
|
||||
: ((config.plugins as Record<string, unknown> | undefined) || {});
|
||||
const entries = (plugins.entries as Record<string, unknown> | undefined) || {};
|
||||
const moonshot = (entries.moonshot as Record<string, unknown> | undefined) || {};
|
||||
const moonshotConfig = (moonshot.config as Record<string, unknown> | undefined) || {};
|
||||
const currentWebSearch = (moonshotConfig.webSearch as Record<string, unknown> | 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<string, unknown>).web as Record<string, unknown>).search as Record<string, unknown>).kimi as Record<string, unknown>);
|
||||
expect(kimi).not.toHaveProperty('apiKey');
|
||||
expect(kimi.baseUrl).toBe('https://api.moonshot.cn/v1');
|
||||
const tools = (result.tools as Record<string, unknown> | undefined) || {};
|
||||
const web = (tools.web as Record<string, unknown> | undefined) || {};
|
||||
const search = (web.search as Record<string, unknown> | undefined) || {};
|
||||
const moonshot = ((((result.plugins as Record<string, unknown>).entries as Record<string, unknown>).moonshot as Record<string, unknown>).config as Record<string, unknown>).webSearch as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const load = plugins.load as string[];
|
||||
const moonshot = ((((result.plugins as Record<string, unknown>).entries as Record<string, unknown>).moonshot as Record<string, unknown>).config as Record<string, unknown>).webSearch as Record<string, unknown>;
|
||||
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user