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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user