Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 89 additions & 3 deletions src/connectors/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
from fastapi import HTTPException

from src.connectors.base import LLMBackend
from src.core.common.exceptions import BackendError, ServiceUnavailableError
from src.core.common.exceptions import (
AuthenticationError,
BackendError,
ServiceUnavailableError,
)
from src.core.config.app_config import AppConfig # Added
from src.core.domain.chat import (
ChatRequest,
Expand Down Expand Up @@ -348,7 +352,12 @@ async def chat_completions( # type: ignore[override]
) -> ResponseEnvelope | StreamingResponseEnvelope:
# Resolve base configuration
base_api_url, headers = await self._resolve_gemini_api_config(
gemini_api_base_url, openrouter_api_base_url, api_key, **kwargs
gemini_api_base_url,
openrouter_api_base_url,
api_key,
openrouter_headers_provider=openrouter_headers_provider,
key_name=key_name,
**kwargs,
)
if identity:
headers.update(identity.get_resolved_headers(None))
Expand Down Expand Up @@ -449,11 +458,31 @@ async def chat_completions( # type: ignore[override]
model_url, payload, headers, effective_model
)

def _build_openrouter_header_context(self) -> dict[str, str]:
referer = "http://localhost:8000"
title = "InterceptorProxy"

identity = getattr(self.config, "identity", None)
if identity is not None:
referer = (
getattr(getattr(identity, "url", None), "default_value", referer)
or referer
)
title = (
getattr(getattr(identity, "title", None), "default_value", title)
or title
)

return {"app_site_url": referer, "app_x_title": title}

async def _resolve_gemini_api_config(
self,
gemini_api_base_url: str | None,
openrouter_api_base_url: str | None,
api_key: str | None,
*,
openrouter_headers_provider: Callable[[Any, str], dict[str, str]] | None = None,
key_name: str | None = None,
**kwargs: Any,
) -> tuple[str, dict[str, str]]:
# Prefer explicit params, then kwargs, then instance attributes set during initialize
Expand All @@ -469,7 +498,64 @@ async def _resolve_gemini_api_config(
status_code=500,
detail="Gemini API base URL and API key must be provided.",
)
return base.rstrip("/"), ensure_loop_guard_header({"x-goog-api-key": key})
normalized_base = base.rstrip("/")

using_openrouter = openrouter_api_base_url is not None

headers: dict[str, str]
if using_openrouter:
Comment on lines +501 to +506
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix OpenRouter mode detection when both bases are provided.

Current logic enables OpenRouter headers if openrouter_api_base_url is non-null even when base resolves to gemini_api_base_url. Compute mode by comparing the chosen base to the OpenRouter base.

-        normalized_base = base.rstrip("/")
-
-        using_openrouter = openrouter_api_base_url is not None
+        normalized_base = base.rstrip("/")
+        openrouter_normalized = (
+            openrouter_api_base_url.rstrip("/") if openrouter_api_base_url else None
+        )
+        using_openrouter = (
+            openrouter_normalized is not None
+            and normalized_base == openrouter_normalized
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
normalized_base = base.rstrip("/")
using_openrouter = openrouter_api_base_url is not None
headers: dict[str, str]
if using_openrouter:
normalized_base = base.rstrip("/")
openrouter_normalized = (
openrouter_api_base_url.rstrip("/") if openrouter_api_base_url else None
)
using_openrouter = (
openrouter_normalized is not None
and normalized_base == openrouter_normalized
)
headers: dict[str, str]
if using_openrouter:
🤖 Prompt for AI Agents
In src/connectors/gemini.py around lines 501 to 506, the code sets
using_openrouter simply based on openrouter_api_base_url being non-null which
incorrectly enables OpenRouter mode when the chosen base is actually the Gemini
base; instead determine the actual base being used (normalize both base and
openrouter_api_base_url with rstrip("/") or similar) and set using_openrouter =
(chosen_base == normalized_openrouter_api_base_url) so headers and mode are only
enabled when the selected base exactly matches the OpenRouter base.

headers = {}
provided_headers: dict[str, str] | None = None

if openrouter_headers_provider is not None:
errors: list[Exception] = []

if key_name is not None:
try:
candidate = openrouter_headers_provider(key_name, key)
except (AttributeError, TypeError) as exc:
errors.append(exc)
else:
if candidate:
provided_headers = dict(candidate)

if provided_headers is None:
context = self._build_openrouter_header_context()
try:
candidate = openrouter_headers_provider(context, key)
except Exception as exc: # pragma: no cover - defensive guard
if errors and logger.isEnabledFor(logging.DEBUG):
logger.debug(
"OpenRouter headers provider rejected key_name input: %s",
errors[-1],
exc_info=True,
)
raise AuthenticationError(
message="OpenRouter headers provider failed to produce headers.",
code="missing_credentials",
) from exc
else:
provided_headers = dict(candidate)

if provided_headers is None:
context = self._build_openrouter_header_context()
provided_headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"HTTP-Referer": context["app_site_url"],
"X-Title": context["app_x_title"],
}

headers.update(provided_headers)
context = self._build_openrouter_header_context()
headers.setdefault("Authorization", f"Bearer {key}")
headers.setdefault("Content-Type", "application/json")
headers.setdefault("HTTP-Referer", context["app_site_url"])
headers.setdefault("X-Title", context["app_x_title"])
else:
headers = {"x-goog-api-key": key}

return normalized_base, ensure_loop_guard_header(headers)

def _apply_generation_config(
self, payload: dict[str, Any], request_data: ChatRequest
Expand Down
86 changes: 86 additions & 0 deletions tests/unit/gemini_connector_tests/test_openrouter_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import asyncio

import httpx

from src.connectors.gemini import GeminiBackend
from src.core.domain.chat import ChatMessage, ChatRequest

OPENROUTER_API_BASE_URL = "https://openrouter.ai/api/v1"


def test_openrouter_headers_provider_used() -> None:
async def run_test() -> None:
seen_headers: dict[str, str] = {}

async def handler(request: httpx.Request) -> httpx.Response:
seen_headers.update({k.lower(): v for k, v in request.headers.items()})
assert str(request.url) == (
f"{OPENROUTER_API_BASE_URL}/v1beta/models/gemini-1:generateContent"
)
return httpx.Response(
status_code=200,
json={
"candidates": [
{"content": {"parts": [{"text": "Hi"}]}},
],
"usageMetadata": {
"promptTokenCount": 1,
"candidatesTokenCount": 1,
"totalTokenCount": 2,
},
},
)

transport = httpx.MockTransport(handler)

async with httpx.AsyncClient(transport=transport) as client:
from src.core.config.app_config import AppConfig
from src.core.services.translation_service import TranslationService

backend = GeminiBackend(
client=client,
config=AppConfig(),
translation_service=TranslationService(),
)

chat_request = ChatRequest(
model="test-model",
messages=[ChatMessage(role="user", content="Hello")],
stream=False,
)
processed_messages = [ChatMessage(role="user", content="Hello")]

provider_calls: list[tuple[object, str]] = []

def provider(arg: object, api_key: str) -> dict[str, str]:
provider_calls.append((arg, api_key))
if isinstance(arg, str):
raise TypeError("legacy signature not supported")
assert isinstance(arg, dict)
assert "app_site_url" in arg
assert "app_x_title" in arg
return {
"Authorization": f"Bearer provided-{api_key}",
"HTTP-Referer": "provided-ref",
}

await backend.chat_completions(
request_data=chat_request,
processed_messages=processed_messages,
effective_model="models/gemini-1",
openrouter_api_base_url=OPENROUTER_API_BASE_URL,
openrouter_headers_provider=provider,
key_name="gemini",
api_key="OPENROUTER_KEY",
)

assert len(provider_calls) == 2
assert isinstance(provider_calls[0][0], str)
assert isinstance(provider_calls[1][0], dict)

assert seen_headers["authorization"] == "Bearer provided-OPENROUTER_KEY"
assert seen_headers["http-referer"] == "provided-ref"
assert seen_headers["content-type"].startswith("application/json")
assert seen_headers["x-llmproxy-loop-guard"] == "1"

asyncio.run(run_test())
Loading