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