Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions electron/services/providers/provider-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude ambiguous 429 probes when overriding auth failures

In the new custom-provider fallback, any probeResult.valid immediately overrides the original /models 401/403. Because classifyProbeResponse treats HTTP 429 as valid, a provider that rate-limits before credential checks can cause invalid keys to be accepted and saved after a 401/403 from /models. This regression is specific to the new auth-fallback path for custom; consider not accepting 429 as proof of valid auth in this branch.

Useful? React with 👍 / 👎.

// Probe also failed — return the original /models auth error
}

return modelsResult;
}

Expand Down
105 changes: 105 additions & 0 deletions tests/unit/provider-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading