mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user