From 11f2e23719ac6ca83cb6b27db31fb6dc6d358f0b Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 29 Jul 2026 08:39:41 -0400 Subject: [PATCH] fix(core): send Gemini API key as header, not URL query param fetchModels listed Gemini models by putting the API key in a ?key= query parameter. Keys in URLs leak through server logs, proxies, and referer headers. Send it as the x-goog-api-key header instead, matching how the rest of the Gemini integration authenticates. Refs continuedev/continue#13052 Authored by: Aaron Lippold --- core/llm/fetchModels.ts | 10 ++++- core/llm/fetchModels.vitest.ts | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 core/llm/fetchModels.vitest.ts diff --git a/core/llm/fetchModels.ts b/core/llm/fetchModels.ts index 88fe1946f95..202ad4fec4f 100644 --- a/core/llm/fetchModels.ts +++ b/core/llm/fetchModels.ts @@ -179,8 +179,14 @@ async function fetchGeminiModels( ): Promise { const base = apiBase || "https://generativelanguage.googleapis.com/v1beta/"; const url = new URL("models", base); - url.searchParams.set("key", apiKey ?? ""); - const response = await fetch(url); + // Send the key as a header, never a query param — URLs leak into server + // logs, proxies, and referer headers. Same auth method the Gemini adapter + // uses (x-goog-api-key). + const response = await fetch(url, { + headers: { + "x-goog-api-key": apiKey ?? "", + }, + }); if (!response.ok) { throw new Error(`Failed to fetch Gemini models: ${response.status}`); } diff --git a/core/llm/fetchModels.vitest.ts b/core/llm/fetchModels.vitest.ts new file mode 100644 index 00000000000..9f8abe3a5dc --- /dev/null +++ b/core/llm/fetchModels.vitest.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { fetchModels } from "./fetchModels"; + +describe("fetchModels gemini", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function stubGeminiListResponse() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + models: [ + { + name: "models/gemini-2.5-pro", + displayName: "Gemini 2.5 Pro", + inputTokenLimit: 1048576, + outputTokenLimit: 65536, + supportedGenerationMethods: ["generateContent"], + }, + ], + }), + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("sends the API key as an x-goog-api-key header, never in the URL", async () => { + const fetchMock = stubGeminiListResponse(); + + const models = await fetchModels("gemini", "test-api-key"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + const parsed = new URL(url.toString()); + expect(parsed.searchParams.get("key")).toBeNull(); + expect(parsed.search).toBe(""); + expect(init.headers["x-goog-api-key"]).toBe("test-api-key"); + expect(parsed.toString()).toBe( + "https://generativelanguage.googleapis.com/v1beta/models", + ); + + expect(models).toEqual([ + { + name: "Gemini 2.5 Pro", + modelId: "gemini-2.5-pro", + icon: "gemini.png", + contextLength: 1048576, + maxTokens: 65536, + supportsTools: true, + }, + ]); + }); + + it("sends an empty x-goog-api-key header when no key is configured", async () => { + const fetchMock = stubGeminiListResponse(); + + await fetchModels("gemini", undefined); + + const [url, init] = fetchMock.mock.calls[0]; + expect(new URL(url.toString()).search).toBe(""); + expect(init.headers["x-goog-api-key"]).toBe(""); + }); + + it("honors a custom apiBase without leaking the key", async () => { + const fetchMock = stubGeminiListResponse(); + + await fetchModels( + "gemini", + "test-api-key", + "https://gateway.example.com/v1beta/", + ); + + const [url] = fetchMock.mock.calls[0]; + expect(url.toString()).toBe("https://gateway.example.com/v1beta/models"); + }); +});