diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index e4d2acdb77..cd5fe17168 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -200,23 +200,70 @@ async function validateAnthropicLikeProvider({ return { valid: true, error: null }; } -async function validateGeminiLikeProvider({ apiKey, baseUrl }: any) { +async function validateGeminiLikeProvider({ apiKey, baseUrl, authType }: any) { if (!baseUrl) { return { valid: false, error: "Missing base URL" }; } - const separator = baseUrl.includes("?") ? "&" : "?"; - const response = await fetch(`${baseUrl}${separator}key=${encodeURIComponent(apiKey)}`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); + // Use the correct auth header based on provider config: + // - gemini (API key): x-goog-api-key + // - gemini-cli (OAuth): Bearer token + const headers: Record = { "Content-Type": "application/json" }; + if (authType === "oauth") { + headers["Authorization"] = `Bearer ${apiKey}`; + } else { + headers["x-goog-api-key"] = apiKey; + } + + const response = await fetch(baseUrl, { method: "GET", headers }); if (response.ok) { return { valid: true, error: null }; } - if (response.status === 401 || response.status === 403) { - return { valid: false, error: "Invalid API key" }; + // 429 = rate limited, but auth is valid + if (response.status === 429) { + return { valid: true, error: null }; + } + + // Google returns 400 (not 401/403) for invalid API keys on the models endpoint. + // Parse the response body to detect auth failures. + if (response.status === 400 || response.status === 401 || response.status === 403) { + const isAuthError = (body: any) => { + const message = (body?.error?.message || "").toLowerCase(); + const reason = body?.error?.details?.[0]?.reason || ""; + const status = body?.error?.status || ""; + const authPatterns = [ + "api key not valid", + "api key expired", + "api key invalid", + "API_KEY_INVALID", + "API_KEY_EXPIRED", + "PERMISSION_DENIED", + "UNAUTHENTICATED", + ]; + return authPatterns.some( + (p) => message.includes(p.toLowerCase()) || reason === p || status === p + ); + }; + + try { + const body = await response.json(); + if (isAuthError(body)) { + return { valid: false, error: "Invalid API key" }; + } + // 401/403 are always auth failures even without matching patterns + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + } catch { + // Unparseable body — 401/403 are always auth failures + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + // 400 without parseable body — likely auth issue for Gemini + return { valid: false, error: "Invalid API key" }; + } } return { valid: false, error: `Validation failed: ${response.status}` }; @@ -847,6 +894,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi return await validateGeminiLikeProvider({ apiKey, baseUrl, + authType: entry.authType, }); } diff --git a/tests/unit/provider-validation-branches.test.mjs b/tests/unit/provider-validation-branches.test.mjs index ad45301e91..f1b5b445e9 100644 --- a/tests/unit/provider-validation-branches.test.mjs +++ b/tests/unit/provider-validation-branches.test.mjs @@ -182,10 +182,10 @@ test("registry openai-like providers report unsupported validation endpoints on ]); }); -test("gemini validation rejects invalid API keys", async () => { +test("gemini validation rejects invalid API keys (401)", async () => { const calls = []; - globalThis.fetch = async (url) => { - calls.push(String(url)); + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), headers: init?.headers }); return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }); }; @@ -197,6 +197,184 @@ test("gemini validation rejects invalid API keys", async () => { assert.equal(result.valid, false); assert.equal(result.error, "Invalid API key"); assert.equal(calls.length, 1); - assert.match(calls[0], /generativelanguage\.googleapis\.com/); - assert.match(calls[0], /key=bad-key/); + assert.match(calls[0].url, /generativelanguage\.googleapis\.com/); + assert.equal(calls[0].headers["x-goog-api-key"], "bad-key"); +}); + +test("gemini validation rejects invalid API keys (400 with API_KEY_INVALID)", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + error: { + code: 400, + message: "API key not valid. Please pass a valid API key.", + status: "INVALID_ARGUMENT", + details: [{ reason: "API_KEY_INVALID" }], + }, + }), + { status: 400 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "bad-key", + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("gemini validation rejects expired API keys (400 with API_KEY_EXPIRED)", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + error: { + code: 400, + message: "API key expired.", + status: "INVALID_ARGUMENT", + details: [{ reason: "API_KEY_EXPIRED" }], + }, + }), + { status: 400 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "expired-key", + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("gemini validation rejects invalid keys via PERMISSION_DENIED status", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + error: { + code: 400, + message: "Request had insufficient authentication scopes.", + status: "PERMISSION_DENIED", + details: [], + }, + }), + { status: 400 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "bad-key", + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("gemini validation accepts valid API key (200)", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + models: [ + { name: "models/gemini-2.5-flash", supportedGenerationMethods: ["generateContent"] }, + ], + }), + { status: 200 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "valid-key", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("gemini validation treats 400 with unknown body as invalid key", async () => { + globalThis.fetch = async () => { + return new Response("not json", { status: 400 }); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "bad-key", + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("gemini validation treats 429 rate limit as valid key", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + error: { + code: 429, + message: "You exceeded your current quota.", + status: "RESOURCE_EXHAUSTED", + }, + }), + { status: 429 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "valid-but-rate-limited-key", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("gemini validation rejects invalid keys via UNAUTHENTICATED status", async () => { + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ + error: { + code: 401, + message: "Request is missing valid authentication credentials.", + status: "UNAUTHENTICATED", + details: [], + }, + }), + { status: 401 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini", + apiKey: "bad-key", + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("gemini-cli validation uses Bearer auth (OAuth)", async () => { + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), headers: init?.headers }); + return new Response( + JSON.stringify({ + models: [{ name: "models/gemini-2.5-flash" }], + }), + { status: 200 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "gemini-cli", + apiKey: "oauth-access-token", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(calls.length, 1); + assert.equal(calls[0].headers["Authorization"], "Bearer oauth-access-token"); + assert.equal(calls[0].headers["x-goog-api-key"], undefined); });