From f75b18a385497ff082df0386130844cf39a1f97c Mon Sep 17 00:00:00 2001 From: octo-patch Date: Wed, 22 Apr 2026 20:01:01 +0800 Subject: [PATCH] fix(validation): fall back to completions probe for custom providers that return 401/403 on /models (fixes #882) Some custom provider implementations (e.g. opencode-go) return 401 or 403 on the OpenAI-compatible GET /models endpoint even when the API key is valid, because they simply don't implement that endpoint. Previously, ClawX would immediately report 'Invalid API key' without retrying, blocking users from saving their custom provider. This change adds a secondary validation probe for the custom provider type: when GET /models returns 401 or 403, ClawX now also tries POST /chat/completions (or /responses for openai-responses protocol) with the same key. If the server accepts the token and returns a non-auth error (e.g. 400 'unknown model: validation-probe'), the key is considered valid. If the fallback probe also returns an auth failure, the original error is returned unchanged. Note: 400 responses with explicit auth error messages from /models are not retried - those indicate the server explicitly rejected the credentials. --- .../services/providers/provider-validation.ts | 21 ++++ tests/unit/provider-validation.test.ts | 105 ++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/electron/services/providers/provider-validation.ts b/electron/services/providers/provider-validation.ts index b880e2277..d3032fb70 100644 --- a/electron/services/providers/provider-validation.ts +++ b/electron/services/providers/provider-validation.ts @@ -216,6 +216,27 @@ async function validateOpenAiCompatibleKey( return await performChatCompletionsProbe(providerType, probeUrl, headers); } + // For custom providers, some implementations return 401/403 on GET /models even with a + // valid key because they don't implement the OpenAI models listing endpoint. Use the + // completions probe as a secondary check: if the server accepts the auth token there, + // the key is valid. If the probe also returns an auth failure, the key is genuinely wrong. + // Note: 400 auth errors (with explicit error messages) are not retried — those indicate + // the server understood the request but rejected the credentials explicitly. + if ( + (modelsResult.status === 401 || modelsResult.status === 403) && + providerType === 'custom' + ) { + console.log( + `[clawx-validate] ${providerType} /models returned auth failure (${modelsResult.status}), ` + + `trying ${apiProtocol} probe as secondary check for custom provider`, + ); + const probeResult = apiProtocol === 'openai-responses' + ? await performResponsesProbe(providerType, probeUrl, headers) + : await performChatCompletionsProbe(providerType, probeUrl, headers); + if (probeResult.valid) return probeResult; + // Probe also failed — return the original /models auth error + } + return modelsResult; } diff --git a/tests/unit/provider-validation.test.ts b/tests/unit/provider-validation.test.ts index 7e1ca5290..e041a735a 100644 --- a/tests/unit/provider-validation.test.ts +++ b/tests/unit/provider-validation.test.ts @@ -373,6 +373,111 @@ describe('validateApiKeyWithProvider', () => { ); }); + it('falls back to /chat/completions for custom provider when /models returns 401', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Unauthorized' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Unknown model: validation-probe' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-custom-valid', { + baseUrl: 'https://custom.example.com/v1', + apiProtocol: 'openai-completions', + }); + + expect(result).toMatchObject({ valid: true }); + expect(proxyAwareFetch).toHaveBeenCalledTimes(2); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 1, + 'https://custom.example.com/v1/models?limit=1', + expect.anything(), + ); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 2, + 'https://custom.example.com/v1/chat/completions', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('falls back to /responses for custom provider when /models returns 403', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Forbidden' } }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Unknown model: validation-probe' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-custom-valid-responses', { + baseUrl: 'https://custom.example.com/v1', + apiProtocol: 'openai-responses', + }); + + expect(result).toMatchObject({ valid: true }); + expect(proxyAwareFetch).toHaveBeenCalledTimes(2); + expect(proxyAwareFetch).toHaveBeenNthCalledWith( + 2, + 'https://custom.example.com/v1/responses', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('reports invalid key for custom provider when /models returns 401 and probe also returns auth failure', async () => { + proxyAwareFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Unauthorized' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Invalid API key' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('custom', 'sk-custom-bad-key', { + baseUrl: 'https://custom.example.com/v1', + apiProtocol: 'openai-completions', + }); + + expect(result).toMatchObject({ valid: false }); + expect(proxyAwareFetch).toHaveBeenCalledTimes(2); + }); + + it('does not apply auth-probe fallback for non-custom provider types returning 401 on /models', async () => { + proxyAwareFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Unauthorized' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation'); + const result = await validateApiKeyWithProvider('openai', 'sk-bad-openai-key'); + + expect(result).toMatchObject({ valid: false, error: 'Invalid API key' }); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + }); + it('treats localized auth-like 400 probe responses as invalid after fallback', async () => { proxyAwareFetch .mockResolvedValueOnce(