fix(proxy): send the Gemini pass-through key in the x-goog-api-key header

The Gemini pass-through route sent the provider key as the `?key=` query
parameter, so the key was part of every upstream URL. Send it as
`x-goog-api-key`, as the non-pass-through Gemini code and Google's SDKs do.

The client's `key` query parameter (the LiteLLM key) is dropped via a new
`excluded_query_params` argument so it is not forwarded upstream; other query
params such as `alt=sse` still pass through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyhdFEF6JLeLLgdgVWcSUT
This commit is contained in:
taxfree-python 2026-10-15 17:49:04 +09:00
parent e9b1c11098
commit ca6a1846ae
3 changed files with 88 additions and 10 deletions

View file

@ -427,9 +427,6 @@ async def gemini_proxy_route(
raise Exception(
"Required 'GEMINI_API_KEY'/'GOOGLE_API_KEY' in environment to make pass-through calls to Google AI Studio."
)
# Merge query parameters, giving precedence to those in updated_url
merged_params: Final = dict(request.query_params)
merged_params.update({"key": gemini_api_key})
## check for streaming
is_streaming_request = False
@ -440,9 +437,12 @@ async def gemini_proxy_route(
endpoint_func: Final = create_pass_through_route(
endpoint=endpoint,
target=str(updated_url),
custom_headers={"x-goog-api-key": gemini_api_key},
custom_llm_provider="gemini",
is_streaming_request=is_streaming_request,
query_params=merged_params,
query_params=dict(request.query_params),
# `key` is the LiteLLM virtual key; the provider key goes in the x-goog-api-key header.
excluded_query_params=("key",),
) # dynamically construct pass-through endpoint based on incoming path
received_value: Final = await endpoint_func(
request,

View file

@ -6,7 +6,16 @@ import posixpath
import re
import traceback
from base64 import b64encode
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable, Mapping, MutableMapping, Sequence
from collections.abc import (
AsyncGenerator,
AsyncIterator,
Callable,
Collection,
Iterable,
Mapping,
MutableMapping,
Sequence,
)
from dataclasses import dataclass
from datetime import datetime
from functools import reduce
@ -1206,6 +1215,12 @@ def _unfiltered_headers(headers: Mapping[str, str]) -> Mapping[str, str]:
return headers
def _forwarded_query_params(
params: Mapping[str, Any], excluded: Collection[str]
) -> dict[str, Any] | None: # mutable-ok: rewrite_query_ids and the httpx request helpers take a plain dict
return {k: v for k, v in params.items() if k not in excluded} or None
def _json_object(value: object) -> Mapping[str, object]:
return cast("Mapping[str, object]", value) if isinstance(value, Mapping) else MappingProxyType({}) # cast-ok: json
@ -1256,6 +1271,7 @@ async def pass_through_request(
merge_query_params: bool | None = False,
query_params: dict | None = None,
default_query_params: dict | None = None,
excluded_query_params: Collection[str] = (),
stream: bool | None = None,
cost_per_request: float | None = None,
custom_llm_provider: str | None = None,
@ -1277,6 +1293,7 @@ async def pass_through_request(
merge_query_params: Whether to merge query params
query_params: The query params
default_query_params: The default query params to be applied if not overridden by client
excluded_query_params: Query param names dropped before the request is sent upstream
stream: Whether to stream the response
cost_per_request: Optional field - cost per request to the target endpoint
custom_llm_provider: Optional field - custom LLM provider for the endpoint
@ -1323,7 +1340,9 @@ async def pass_through_request(
parent_span=user_api_key_dict.parent_otel_span,
)
requested_query_params: dict | None = query_params or dict(request.query_params) or None
requested_query_params: dict[str, Any] | None = _forwarded_query_params(
query_params or request.query_params, excluded_query_params
)
endpoint_type: Final[EndpointType] = HttpPassThroughEndpointHelpers.get_endpoint_type(
str(url), custom_llm_provider
@ -2325,6 +2344,7 @@ def create_pass_through_route(
is_streaming_request: bool | None = False,
query_params: dict | None = None,
default_query_params: dict | None = None,
excluded_query_params: Collection[str] = (),
guardrails: dict[str, object] | None = None,
config_file_path: str | None = None,
timeout: float | None = None,
@ -2471,6 +2491,7 @@ def create_pass_through_route(
merge_query_params=cast(bool | None, param_merge_query_params),
query_params=final_query_params,
default_query_params=cast(dict | None, param_default_query_params),
excluded_query_params=excluded_query_params,
stream=is_stream,
custom_body=final_custom_body,
cost_per_request=cast(float | None, param_cost_per_request),

View file

@ -212,9 +212,7 @@ async def test_gemini_pass_through_endpoint_forwards_key_and_body(
monkeypatch.setenv("GEMINI_API_KEY", "gemini-test-key")
endpoint: Final = "v1beta/models/gemini-2.5-flash:countTokens"
upstream: Final = respx_mock.post(
url__startswith=f"https://generativelanguage.googleapis.com/{endpoint}"
).mock(
upstream: Final = respx_mock.post(url__startswith=f"https://generativelanguage.googleapis.com/{endpoint}").mock(
return_value=httpx.Response(
200,
json={"totalTokens": 9},
@ -252,7 +250,9 @@ async def test_gemini_pass_through_endpoint_forwards_key_and_body(
assert upstream.call_count == 1
outbound: Final = upstream.calls[0].request
assert outbound.url.path == f"/{endpoint}"
assert outbound.url.params["key"] == "gemini-test-key"
assert outbound.headers["x-goog-api-key"] == "gemini-test-key"
assert "key" not in outbound.url.params
assert "sk-9876" not in str(outbound.url)
assert json.loads(outbound.content) == json.loads(body)
@pytest.mark.asyncio
@ -546,3 +546,60 @@ def test_vertex_pass_through_forwards_a_request_without_betas_despite_a_malforme
assert (response.status_code, response.json()) == (200, _VERTEX_CLAUDE_REPLY)
assert "anthropic-beta" not in upstream.calls.last.request.headers
@pytest.mark.asyncio
@pytest.mark.parametrize("client_key_in_header", [False, True])
async def test_gemini_pass_through_endpoint_keeps_provider_key_out_of_url(
respx_mock: "respx.MockRouter",
httpx_transport: None,
monkeypatch: pytest.MonkeyPatch,
client_key_in_header: bool,
) -> None:
"""The provider key is sent in x-goog-api-key, the LiteLLM key is never forwarded, other query params pass through."""
import httpx
from fastapi import Request, Response
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
gemini_proxy_route,
)
monkeypatch.setenv("GEMINI_API_KEY", "gemini-test-key")
endpoint: Final = "v1beta/models/gemini-2.5-flash:streamGenerateContent"
upstream: Final = respx_mock.get(url__startswith=f"https://generativelanguage.googleapis.com/{endpoint}").mock(
return_value=httpx.Response(200, json={"ok": True})
)
# No content-type: the request's own query string is the only source of query params.
headers: Final = [(b"x-goog-api-key", b"sk-9876")] if client_key_in_header else []
query_string: Final = b"alt=sse" if client_key_in_header else b"alt=sse&key=sk-9876"
scope: Final = {
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": f"/gemini/{endpoint}",
"raw_path": f"/gemini/{endpoint}".encode(),
"root_path": "",
"query_string": query_string,
"headers": headers,
"client": ("127.0.0.1", 51234),
"server": ("proxy.local", 4000),
"state": {},
}
async def receive() -> dict:
return {"type": "http.request", "body": b"", "more_body": False}
response: Final = await gemini_proxy_route(
endpoint=endpoint,
request=Request(scope, receive),
fastapi_response=Response(),
)
assert response.status_code == 200
assert upstream.call_count == 1
outbound: Final = upstream.calls[0].request
assert outbound.headers["x-goog-api-key"] == "gemini-test-key"
assert dict(outbound.url.params) == {"alt": "sse"}
assert "sk-9876" not in str(outbound.url)
assert "sk-9876" not in str(outbound.headers)