fix custom provider API key validation fallback (#773)

Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-04-06 14:35:09 +08:00
committed by GitHub
Unverified
parent e640316382
commit 91c735c9f4
8 changed files with 554 additions and 80 deletions

View File

@@ -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,

View File

@@ -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');
}
}