fix(providers): resolve Gemini validation 4xx errors with header-based auth (#977)

Switch validateGeminiLikeProvider from query-param auth (?key=) to
x-goog-api-key header auth, matching the actual request pipeline.

Parse Google error response bodies to distinguish auth failures
(API_KEY_INVALID, API_KEY_EXPIRED, PERMISSION_DENIED) from other
400 errors. Google returns 400 (not 401/403) for invalid keys.

Add 5 new test cases covering 400/401 rejection paths and success.

Fixes #976

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
This commit is contained in:
Paijo
2026-04-04 17:32:51 +07:00
committed by GitHub
parent 606824d282
commit 9148dc9e03
2 changed files with 239 additions and 13 deletions

View File

@@ -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<string, string> = { "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,
});
}

View File

@@ -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);
});