From f7374ad80043f9f1bb924517a2282cd89fc9960a Mon Sep 17 00:00:00 2001 From: balazss Date: Tue, 17 Mar 2026 20:05:28 -0700 Subject: [PATCH 01/13] feat(gemini): add oauth login and google code assist provider support --- docs/my-website/docs/providers/gemini.md | 21 +- .../docs/providers/google_code_assist.md | 84 ++++++ docs/my-website/sidebars.js | 1 + litellm/llms/gemini/authenticator.py | 260 ++++++++++++++++++ litellm/llms/gemini/common_utils.py | 116 ++++++++ litellm/llms/gemini/fallback_handler.py | 47 ++++ litellm/llms/google_code_assist/chat.py | 226 +++++++++++++++ .../llms/google_code_assist/transformation.py | 195 +++++++++++++ litellm/llms/vertex_ai/common_utils.py | 33 +-- litellm/llms/vertex_ai/vertex_llm_base.py | 43 ++- litellm/main.py | 129 +++++++-- litellm/proxy/client/cli/README.md | 12 + litellm/proxy/client/cli/commands/gemini.py | 19 ++ litellm/proxy/client/cli/main.py | 2 + litellm/types/utils.py | 1 + litellm/utils.py | 13 + poetry.lock | 73 ++--- .../test_google_code_assist.py | 127 +++++++++ 18 files changed, 1324 insertions(+), 78 deletions(-) create mode 100644 docs/my-website/docs/providers/google_code_assist.md create mode 100644 litellm/llms/gemini/authenticator.py create mode 100644 litellm/llms/gemini/fallback_handler.py create mode 100644 litellm/llms/google_code_assist/chat.py create mode 100644 litellm/llms/google_code_assist/transformation.py create mode 100644 litellm/proxy/client/cli/commands/gemini.py create mode 100644 tests/test_litellm/llms/google_code_assist/test_google_code_assist.py diff --git a/docs/my-website/docs/providers/gemini.md b/docs/my-website/docs/providers/gemini.md index 0aaf3d5ae81..c38f8121ba9 100644 --- a/docs/my-website/docs/providers/gemini.md +++ b/docs/my-website/docs/providers/gemini.md @@ -34,6 +34,26 @@ import os os.environ["GEMINI_API_KEY"] = "your-api-key" ``` +## OAuth Authentication (Optional) + +You can also authenticate Gemini requests with OAuth credentials. + +```bash +export GEMINI_OAUTH_CLIENT_ID="" +export GEMINI_OAUTH_CLIENT_SECRET="" +litellm-proxy gemini login +``` + +LiteLLM then reads OAuth credentials from: + +1. `GEMINI_OAUTH_TOKEN` +2. `GEMINI_CREDENTIALS_PATH` +3. `~/.config/litellm/gemini_oauth/oauth_creds.json` +4. `~/.gemini/oauth_creds.json` +5. `~/.config/gcloud/application_default_credentials.json` + +For Google Code Assist-specific usage, see [Google Code Assist](./google_code_assist.md). + ## Sample Usage ```python from litellm import completion @@ -2459,4 +2479,3 @@ LiteLLM automatically calculates costs using `output_cost_per_image_token` from ``` For more details, see [Google's Gemini pricing documentation](https://ai.google.dev/gemini-api/docs/pricing). - diff --git a/docs/my-website/docs/providers/google_code_assist.md b/docs/my-website/docs/providers/google_code_assist.md new file mode 100644 index 00000000000..6dd3491445f --- /dev/null +++ b/docs/my-website/docs/providers/google_code_assist.md @@ -0,0 +1,84 @@ +# Google Code Assist + +Use Google Code Assist models through LiteLLM using Gemini OAuth credentials. + +| Property | Details | +|-------|-------| +| Description | Google Code Assist API via Google Cloud Code Assist backend | +| Provider Route on LiteLLM | `google_code_assist/` | +| Supported Endpoints | `/chat/completions` | +| Base Endpoint | `https://cloudcode-pa.googleapis.com` | + +## Model Format + +Use: + +`google_code_assist/` + +Example: + +`google_code_assist/gemini-2.5-pro` + +## Authentication + +Google Code Assist requires OAuth credentials (not API key auth). + +LiteLLM checks credentials in this order: + +1. `GEMINI_OAUTH_TOKEN` +2. `GEMINI_CREDENTIALS_PATH` +3. `~/.config/litellm/gemini_oauth/oauth_creds.json` +4. `~/.gemini/oauth_creds.json` +5. `~/.config/gcloud/application_default_credentials.json` + +### Login via CLI + +```bash +export GEMINI_OAUTH_CLIENT_ID="" +export GEMINI_OAUTH_CLIENT_SECRET="" +litellm-proxy gemini login +``` + +This opens a browser loopback OAuth flow and stores credentials locally. + +## Usage - LiteLLM Python SDK + +```python +from litellm import completion + +response = completion( + model="google_code_assist/gemini-2.5-pro", + messages=[{"role": "user", "content": "Write a Python function to parse CSV safely."}], +) + +print(response.choices[0].message.content) +``` + +## Usage - LiteLLM Proxy + +```yaml +model_list: + - model_name: google-code-assist + litellm_params: + model: google_code_assist/gemini-2.5-pro +``` + +```bash +litellm --config /path/to/config.yaml +``` + +```bash +curl http://localhost:4000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "model": "google-code-assist", + "messages": [{"role": "user", "content": "Refactor this code for readability."}] + }' +``` + +## Optional Params + +- `google_code_assist_project`: Optional project override sent to the Code Assist backend request. + +LiteLLM also performs the required `loadCodeAssist` handshake before `generateContent` automatically. diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 79a0279bad5..94a0a68a40b 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -796,6 +796,7 @@ const sidebars = { label: "Google AI Studio", items: [ "providers/gemini", + "providers/google_code_assist", "providers/gemini/videos", "providers/google_ai_studio/files", "providers/google_ai_studio/image_gen", diff --git a/litellm/llms/gemini/authenticator.py b/litellm/llms/gemini/authenticator.py new file mode 100644 index 00000000000..c5f6aab8a70 --- /dev/null +++ b/litellm/llms/gemini/authenticator.py @@ -0,0 +1,260 @@ +import json +import os +import time +import webbrowser +import http.server +import html +import urllib.parse +import secrets +import hashlib +import base64 +from typing import Any, Dict + +import httpx + +from litellm._logging import verbose_logger +from litellm.secret_managers.main import get_secret_str + +GEMINI_SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/generative-language", +] + +# Google OAuth URLs +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" + + +class GeminiAuthenticator: + def __init__(self) -> None: + """Initialize the Gemini authenticator with configurable token paths.""" + # Token storage paths + self.token_dir = os.getenv( + "GEMINI_OAUTH_TOKEN_DIR", + os.path.expanduser("~/.config/litellm/gemini_oauth"), + ) + self.oauth_creds_file = os.path.join( + self.token_dir, + os.getenv("GEMINI_OAUTH_CREDS_FILE", "oauth_creds.json"), + ) + self._ensure_token_dir() + + @staticmethod + def _get_oauth_client_credentials() -> tuple[str, str]: + client_id = get_secret_str("GEMINI_OAUTH_CLIENT_ID") + client_secret = get_secret_str("GEMINI_OAUTH_CLIENT_SECRET") + + if not client_id or not client_secret: + raise ValueError( + "Missing Gemini OAuth client credentials. " + "Set GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET." + ) + return client_id, client_secret + + def get_token(self) -> str: + """ + Get the OAuth token, refreshing if necessary. + + Returns: + str: The Gemini access token. + + Raises: + Exception: If unable to obtain or refresh an access token. + """ + try: + if os.path.exists(self.oauth_creds_file): + with open(self.oauth_creds_file, "r") as f: + creds = json.load(f) + + # Check if access_token exists and is valid (rough check) + if ( + creds.get("access_token") + and creds.get("expires_at", 0) > time.time() + 60 + ): + return creds.get("access_token") + + # If expired but has refresh_token, try to refresh + if creds.get("refresh_token"): + verbose_logger.debug( + "Gemini access token expired, refreshing..." + ) + return self._refresh_token(creds.get("refresh_token")) + except Exception as e: + verbose_logger.warning(f"Error reading Gemini OAuth credentials: {e}") + + # If we get here, we need to log in + verbose_logger.info("Starting Gemini OAuth login flow...") + creds = self._login() + self._write_oauth_creds(creds) + return creds.get("access_token") + + def _refresh_token(self, refresh_token: str) -> str: + """Refresh the access token using the refresh token.""" + client_id, client_secret = self._get_oauth_client_credentials() + data = { + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + } + resp = httpx.post(GOOGLE_TOKEN_URL, data=data) + resp.raise_for_status() + new_creds = resp.json() + + # Load existing creds to merge if possible + creds = {} + if os.path.exists(self.oauth_creds_file): + try: + with open(self.oauth_creds_file, "r") as f: + creds = json.load(f) + except Exception: + pass + + creds.update(new_creds) + if "expires_in" in new_creds: + creds["expires_at"] = time.time() + new_creds["expires_in"] + + self._write_oauth_creds(creds) + + return creds.get("access_token") + + def _ensure_token_dir(self) -> None: + """Ensure the token directory exists.""" + if not os.path.exists(self.token_dir): + os.makedirs(self.token_dir, mode=0o700, exist_ok=True) + else: + try: + os.chmod(self.token_dir, 0o700) + except OSError: + pass + + def _write_oauth_creds(self, creds: Dict[str, Any]) -> None: + """ + Write oauth credentials with user-only permissions. + """ + fd = os.open( + self.oauth_creds_file, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(creds, f) + try: + os.chmod(self.oauth_creds_file, 0o600) + except OSError: + pass + + def _login(self) -> Dict[str, Any]: + """Perform loopback flow login.""" + client_id, client_secret = self._get_oauth_client_credentials() + # PKCE + code_verifier = secrets.token_urlsafe(64) + code_challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()) + .decode() + .replace("=", "") + ) + + state = secrets.token_urlsafe(32) + + # Local web server for callback + auth_code = None + error = None + + class CallbackHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + nonlocal auth_code, error + parsed_url = urllib.parse.urlparse(self.path) + if parsed_url.path != "/oauth2callback": + self.send_response(404) + self.end_headers() + return + + query = parsed_url.query + params = urllib.parse.parse_qs(query) + + if params.get("state", [None])[0] != state: + error = "state_mismatch" + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

Authentication failed

State mismatch. Possible CSRF attack.

" + ) + elif "code" in params: + auth_code = params["code"][0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + b"

Authentication successful!

You can close this tab and return to the terminal.

" + ) + elif "error" in params: + error = params["error"][0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + ( + "

Authentication failed

" + f"{html.escape(error)}" + "

" + ).encode() + ) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + # Suppress logging to avoid noise in the terminal + pass + + server = http.server.HTTPServer(("127.0.0.1", 0), CallbackHandler) + port = server.server_port + redirect_uri = f"http://127.0.0.1:{port}/oauth2callback" + + params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": " ".join(GEMINI_SCOPES), + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "access_type": "offline", + "prompt": "consent", + } + auth_url = f"{GOOGLE_AUTH_URL}?{urllib.parse.urlencode(params)}" + + print( # noqa: T201 + f"Please visit the following URL to authenticate:\n\n{auth_url}\n" + ) + webbrowser.open(auth_url) + + # Wait for callback + server.handle_request() + server.server_close() + + if error: + raise Exception(f"OAuth login failed: {error}") + if not auth_code: + raise Exception("OAuth login failed: No code received") + + # Exchange code for token + data = { + "client_id": client_id, + "client_secret": client_secret, + "code": auth_code, + "code_verifier": code_verifier, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + } + resp = httpx.post(GOOGLE_TOKEN_URL, data=data) + resp.raise_for_status() + creds = resp.json() + if "expires_in" in creds: + creds["expires_at"] = time.time() + creds["expires_in"] + + return creds diff --git a/litellm/llms/gemini/common_utils.py b/litellm/llms/gemini/common_utils.py index 87c107fab37..f93a105f09f 100644 --- a/litellm/llms/gemini/common_utils.py +++ b/litellm/llms/gemini/common_utils.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional, Union import httpx import litellm +from litellm._logging import verbose_logger from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -154,6 +155,121 @@ def get_api_key_from_env() -> Optional[str]: return get_secret_str("GOOGLE_API_KEY") or get_secret_str("GEMINI_API_KEY") +def should_fallback_to_google_code_assist(error: Exception) -> bool: + """ + Returns True if the error indicates missing OAuth scope for Gemini calls. + """ + return "ACCESS_TOKEN_SCOPE_INSUFFICIENT" in str(error) + + +def get_gemini_oauth_token() -> Optional[dict]: # noqa: PLR0915 + """ + Returns the Gemini OAuth token and metadata. + Check: + 1. GEMINI_OAUTH_TOKEN env var + 2. GEMINI_CREDENTIALS_PATH env var + 3. ~/.config/litellm/gemini_oauth/oauth_creds.json + 4. ~/.gemini/oauth_creds.json (gemini-cli default) + 5. ~/.config/gcloud/application_default_credentials.json (Standard ADC) + """ + import json + import time + from pathlib import Path + + def _is_expired(creds_data: dict) -> bool: + expires_at = creds_data.get("expires_at") + if isinstance(expires_at, (int, float)): + return expires_at <= (time.time() + 60) + + expiry = creds_data.get("expiry") or creds_data.get("expires_on") + if isinstance(expiry, str): + try: + expires_dt = datetime.datetime.fromisoformat( + expiry.replace("Z", "+00:00") + ) + return expires_dt.timestamp() <= (time.time() + 60) + except Exception: + return False + + return False + + # 1. Check GEMINI_OAUTH_TOKEN + token = get_secret_str("GEMINI_OAUTH_TOKEN") + if token: + return {"token": token} + + # 2. Check GEMINI_CREDENTIALS_PATH or default paths + paths_to_check = [] + env_path = get_secret_str("GEMINI_CREDENTIALS_PATH") + if env_path: + paths_to_check.append(Path(env_path).expanduser()) + + paths_to_check.extend( + [ + Path("~/.config/litellm/gemini_oauth/oauth_creds.json").expanduser(), + Path("~/.gemini/oauth_creds.json").expanduser(), + Path("~/.config/gcloud/application_default_credentials.json").expanduser(), + ] + ) + + for creds_path in paths_to_check: + if creds_path.exists(): + try: + # Try to use google-auth if available + try: + from google.oauth2 import credentials + from google.auth.transport.requests import Request + + creds = credentials.Credentials.from_authorized_user_file( + str(creds_path) + ) + if not creds.valid: + verbose_logger.debug( + f"Refreshing Gemini token for {creds_path}" + ) + creds.refresh(Request()) + if creds.token: + result = {"token": creds.token} + # Extract project ID if present + with open(creds_path, "r") as f: + creds_data = json.load(f) + project_id = creds_data.get( + "quota_project_id" + ) or creds_data.get("project_id") + if project_id: + result["project_id"] = project_id + return result + except Exception: + # Fallback to manual reading + with open(creds_path, "r") as f: + creds_data = json.load(f) + if _is_expired(creds_data): + verbose_logger.warning( + "Gemini OAuth token appears expired in %s. " + "Run `litellm-proxy gemini login` to refresh credentials.", + creds_path, + ) + continue + token = creds_data.get("access_token") + if not token and "token" in creds_data: + token = creds_data["token"].get("accessToken") + + if token: + result = {"token": token} + project_id = creds_data.get( + "quota_project_id" + ) or creds_data.get("project_id") + if project_id: + result["project_id"] = project_id + return result + except Exception as e: + verbose_logger.error( + f"Error reading Gemini credentials from {creds_path}: {e}" + ) + + return None + + class GoogleAIStudioTokenCounter(BaseTokenCounter): """Token counter implementation for Google AI Studio provider.""" diff --git a/litellm/llms/gemini/fallback_handler.py b/litellm/llms/gemini/fallback_handler.py new file mode 100644 index 00000000000..60343b884cd --- /dev/null +++ b/litellm/llms/gemini/fallback_handler.py @@ -0,0 +1,47 @@ +from typing import Any, Awaitable, Callable, Dict + +from litellm._logging import verbose_logger +from litellm.llms.gemini.common_utils import should_fallback_to_google_code_assist +from litellm.llms.google_code_assist.chat import GoogleCodeAssistChat + + +async def run_gemini_acompletion_with_code_assist_fallback( + primary_call: Awaitable[Any], + fallback_kwargs: Dict[str, Any], +) -> Any: + """ + Execute Gemini async completion and fallback to Google Code Assist when + OAuth scope is insufficient. + """ + try: + return await primary_call + except Exception as e: + if not should_fallback_to_google_code_assist(e): + raise e + + verbose_logger.warning( + "Gemini request failed with ACCESS_TOKEN_SCOPE_INSUFFICIENT. " + "Falling back to google_code_assist." + ) + return await GoogleCodeAssistChat().acompletion(**fallback_kwargs) + + +def run_gemini_completion_with_code_assist_fallback( + primary_call: Callable[[], Any], + fallback_kwargs: Dict[str, Any], +) -> Any: + """ + Execute Gemini sync completion and fallback to Google Code Assist when + OAuth scope is insufficient. + """ + try: + return primary_call() + except Exception as e: + if not should_fallback_to_google_code_assist(e): + raise e + + verbose_logger.warning( + "Gemini request failed with ACCESS_TOKEN_SCOPE_INSUFFICIENT. " + "Falling back to google_code_assist." + ) + return GoogleCodeAssistChat().completion(**fallback_kwargs) diff --git a/litellm/llms/google_code_assist/chat.py b/litellm/llms/google_code_assist/chat.py new file mode 100644 index 00000000000..af6fa7abc01 --- /dev/null +++ b/litellm/llms/google_code_assist/chat.py @@ -0,0 +1,226 @@ +import httpx +from typing import Any, Optional + +import litellm +from litellm._logging import verbose_logger +from litellm.llms.custom_httpx.http_handler import _get_httpx_client, AsyncHTTPHandler +from .transformation import GoogleCodeAssistConfig, GoogleCodeAssistError + + +class GoogleCodeAssistChat: + """ + Handler for Google Code Assist API. + Provides both synchronous and asynchronous completion methods. + """ + + def __init__(self) -> None: + self.config = GoogleCodeAssistConfig() + + def completion( + self, + model: str, + messages: list, + model_response: litellm.utils.ModelResponse, + print_verbose: Any, + logging_obj: Any, + optional_params: dict, + litellm_params: dict, + logger_fn=None, + ) -> litellm.utils.ModelResponse: + """ + Synchronous completion for Google Code Assist. + """ + try: + from litellm.llms.gemini.common_utils import get_gemini_oauth_token + + # 1. Get OAuth data + gemini_auth_data = get_gemini_oauth_token() + if not gemini_auth_data: + raise GoogleCodeAssistError( + status_code=401, + message="Missing Gemini OAuth token. Run 'litellm-proxy gemini login' or set GEMINI_OAUTH_TOKEN.", + ) + + token = gemini_auth_data.get("token") + initial_project_id = gemini_auth_data.get("project_id") + + client = _get_httpx_client() + + # 2. MANDATORY HANDSHAKE: loadCodeAssist + final_project_id = self._handle_handshake(client, token, initial_project_id) + litellm_params["google_code_assist_project"] = final_project_id + + # 3. Transform request + data = self.config.transform_request( + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + ) + + # 4. Call Completion API + url = "https://cloudcode-pa.googleapis.com/v1internal:generateContent" + headers = self._get_headers(token) + + response = client.post( + url=url, + headers=headers, + json=data, + ) + response.raise_for_status() + + # 5. Transform response + return self.config.transform_response( + model=model, + raw_response=response, + model_response=model_response, + logging_obj=logging_obj, + request_data=data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=None, + ) + + except Exception as e: + raise self._handle_error(e) + + async def acompletion( + self, + model: str, + messages: list, + model_response: litellm.utils.ModelResponse, + print_verbose: Any, + logging_obj: Any, + optional_params: dict, + litellm_params: dict, + logger_fn=None, + ) -> litellm.utils.ModelResponse: + """ + Asynchronous completion for Google Code Assist. + """ + try: + from litellm.llms.gemini.common_utils import get_gemini_oauth_token + + gemini_auth_data = get_gemini_oauth_token() + if not gemini_auth_data: + raise GoogleCodeAssistError( + status_code=401, + message="Missing Gemini OAuth token. Run 'litellm-proxy gemini login' or set GEMINI_OAUTH_TOKEN.", + ) + + token = gemini_auth_data.get("token") + initial_project_id = gemini_auth_data.get("project_id") + + async_handler = AsyncHTTPHandler() + + final_project_id = await self._ahandle_handshake( + async_handler, token, initial_project_id + ) + litellm_params["google_code_assist_project"] = final_project_id + + data = self.config.transform_request( + model, messages, optional_params, litellm_params + ) + url = "https://cloudcode-pa.googleapis.com/v1internal:generateContent" + headers = self._get_headers(token) + + response = await async_handler.post(url=url, headers=headers, json=data) + response.raise_for_status() + + return self.config.transform_response( + model=model, + raw_response=response, + model_response=model_response, + logging_obj=logging_obj, + request_data=data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=None, + ) + + except Exception as e: + raise self._handle_error(e) + + def _handle_handshake( + self, client, token: str, initial_project_id: Optional[str] + ) -> Optional[str]: + """Performs the loadCodeAssist handshake to establish session context.""" + load_url = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + load_headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "GeminiCLI/litellm", + } + load_payload = { + "metadata": { + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + } + } + if initial_project_id: + load_payload["cloudaicompanionProject"] = initial_project_id + load_payload["metadata"]["duetProject"] = initial_project_id + + try: + load_resp = client.post(load_url, headers=load_headers, json=load_payload) + load_resp.raise_for_status() + return load_resp.json().get("cloudaicompanionProject") or initial_project_id + except Exception as e: + verbose_logger.debug(f"Gemini Code Assist handshake failed: {e}") + return initial_project_id + + async def _ahandle_handshake( + self, + async_handler: AsyncHTTPHandler, + token: str, + initial_project_id: Optional[str], + ) -> Optional[str]: + """Async version of loadCodeAssist handshake for non-blocking async calls.""" + load_url = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + load_headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "GeminiCLI/litellm", + } + load_payload = { + "metadata": { + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + } + } + if initial_project_id: + load_payload["cloudaicompanionProject"] = initial_project_id + load_payload["metadata"]["duetProject"] = initial_project_id + + try: + load_resp = await async_handler.post( + url=load_url, headers=load_headers, json=load_payload + ) + load_resp.raise_for_status() + return load_resp.json().get("cloudaicompanionProject") or initial_project_id + except Exception as e: + verbose_logger.debug(f"Gemini Code Assist async handshake failed: {e}") + return initial_project_id + + def _get_headers(self, token: str) -> dict: + """Returns standard headers for Code Assist API calls.""" + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "GeminiCLI/litellm", + "X-Goog-Api-Client": "litellm-google-code-assist", + } + + def _handle_error(self, e: Exception) -> Exception: + """Centralized error mapping for Code Assist.""" + if isinstance(e, httpx.HTTPStatusError): + return GoogleCodeAssistError( + status_code=e.response.status_code, message=e.response.text + ) + if isinstance(e, GoogleCodeAssistError): + return e + return GoogleCodeAssistError(status_code=500, message=str(e)) diff --git a/litellm/llms/google_code_assist/transformation.py b/litellm/llms/google_code_assist/transformation.py new file mode 100644 index 00000000000..5de7dd725ed --- /dev/null +++ b/litellm/llms/google_code_assist/transformation.py @@ -0,0 +1,195 @@ +import json +import uuid +import copy +import httpx +from typing import Any, List, Optional + +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.types.utils import ModelResponse +from ..vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig + + +class GoogleCodeAssistError(BaseLLMException): + """ + Exception raised for errors in the Google Code Assist API. + """ + + def __init__(self, status_code, message): + super().__init__(status_code=status_code, message=message) + + +class ParsedJSONResponseAdapter: + """ + Adapter that provides the subset of httpx.Response surface used by + VertexGeminiConfig.transform_response. + """ + + def __init__(self, json_data: dict): + self._json = json_data + self.status_code = 200 + self.text = json.dumps(json_data) + self.headers = httpx.Headers({"content-type": "application/json"}) + + def json(self): + return self._json + + +class GoogleCodeAssistConfig(VertexGeminiConfig): + """ + Reference: https://cloud.google.com/gemini/docs/api/reference/rest/v1internal/projects.locations.codeAssist/generateContent + + The class `GoogleCodeAssistConfig` provides configuration for the Google Code Assist API. + It inherits from `VertexGeminiConfig` to provide consistent parameter mapping. + + - `temperature` (float): This controls the degree of randomness in token selection. + - `max_output_tokens` (integer): This sets the limitation for the maximum amount of token in the text output. + - `top_p` (float): The tokens are selected from the most probable to the least probable until the sum of their probabilities equals the `top_p` value. + - `top_k` (integer): The value of `top_k` determines how many of the most probable tokens are considered in the selection. + - `stop_sequences` (List[str]): The set of character sequences that will stop output generation. + """ + + def __init__( + self, + temperature: Optional[float] = None, + max_output_tokens: Optional[int] = None, + top_p: Optional[float] = None, + top_k: Optional[int] = None, + stop_sequences: Optional[list] = None, + ) -> None: + super().__init__( + temperature=temperature, + max_output_tokens=max_output_tokens, + top_p=top_p, + top_k=top_k, + stop_sequences=stop_sequences, + ) + + def get_supported_openai_params(self, model: str) -> List[str]: + return super().get_supported_openai_params(model) + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + messages: list, + ) -> dict: + return super().map_openai_params( + non_default_params, optional_params, model, messages + ) + + def transform_request( + self, + model: str, + messages: list, + optional_params: dict, + litellm_params: dict, + ) -> dict: + """ + Transforms standard LiteLLM request to Code Assist API format. + Matches gemini-cli 'toGenerateContentRequest'. + """ + # 1. Map messages to Gemini format + from ..vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + _transform_system_message, + ) + + # Create a copy to avoid mutating the original list + messages_copy = copy.deepcopy(messages) + + # Separate system instruction + system_instruction, filtered_messages = _transform_system_message( + supports_system_message=True, messages=messages_copy + ) + + # Convert the rest of messages + contents = _gemini_convert_messages_with_history( + messages=filtered_messages, model=model + ) + + # 2. Build vertex-style nested request + generation_config = {} + # Handle parameter mapping + base_params = self.map_openai_params( + {}, optional_params.copy(), model, messages + ) + + for key in ["temperature", "topP", "topK", "maxOutputTokens", "stopSequences"]: + if key in base_params: + generation_config[key] = base_params.pop(key) + + # Support thinkingConfig + if "thinkingConfig" in base_params: + generation_config["thinkingConfig"] = base_params.pop("thinkingConfig") + elif "include_thoughts" in base_params: + generation_config["thinkingConfig"] = { + "includeThoughts": base_params.pop("include_thoughts") + } + elif "thinkingConfig" in optional_params: + generation_config["thinkingConfig"] = optional_params["thinkingConfig"] + elif "include_thoughts" in optional_params: + generation_config["thinkingConfig"] = { + "includeThoughts": optional_params["include_thoughts"] + } + + vertex_request = { + "contents": contents, + "session_id": litellm_params.get("session_id", str(uuid.uuid4())), + } + + if system_instruction: + vertex_request["systemInstruction"] = { + "role": "system", + "parts": system_instruction["parts"], + } + + if generation_config: + vertex_request["generationConfig"] = generation_config + + # 3. Wrap in Code Assist envelope (matches verified gemini-cli structure) + user_prompt_id = f"litellm-{uuid.uuid4()}"[:13] + model_name = model.split("/")[-1] + + ca_request = { + "model": model_name, + "user_prompt_id": user_prompt_id, + "request": vertex_request, + } + + # Add project ID if available + if litellm_params.get("google_code_assist_project"): + ca_request["project"] = litellm_params["google_code_assist_project"] + + return ca_request + + def transform_response( + self, + model: str, + raw_response: Any, + model_response: ModelResponse, + logging_obj: Any, + request_data: dict, + messages: list, + optional_params: dict, + litellm_params: dict, + encoding: Any, + ) -> ModelResponse: + """ + Transforms Code Assist API response to standard LiteLLM format. + """ + data = raw_response.json() + # Code Assist wraps the response in a "response" key + gemini_response = data.get("response", data) + + return super().transform_response( + model=model, + raw_response=ParsedJSONResponseAdapter(gemini_response), + model_response=model_response, + logging_obj=logging_obj, + request_data=request_data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=encoding, + ) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 5895a91f3aa..9bd2e1f45f7 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -338,6 +338,7 @@ def _get_gemini_url( model: str, stream: Optional[bool], gemini_api_key: Optional[str], + gemini_oauth_token: Optional[str] = None, ) -> Tuple[str, str]: from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( VertexGeminiConfig, @@ -348,32 +349,17 @@ def _get_gemini_url( "v1alpha" if VertexGeminiConfig._is_gemini_3_or_newer(model) else "v1beta" ) + endpoint = "generateContent" if mode == "chat": endpoint = "generateContent" if stream is True: endpoint = "streamGenerateContent" - url = "https://generativelanguage.googleapis.com/{}/{}:{}?key={}&alt=sse".format( - api_version, _gemini_model_name, endpoint, gemini_api_key - ) - else: - url = "https://generativelanguage.googleapis.com/{}/{}:{}?key={}".format( - api_version, _gemini_model_name, endpoint, gemini_api_key - ) elif mode == "embedding": endpoint = "embedContent" - url = "https://generativelanguage.googleapis.com/v1beta/{}:{}?key={}".format( - _gemini_model_name, endpoint, gemini_api_key - ) elif mode == "batch_embedding": endpoint = "batchEmbedContents" - url = "https://generativelanguage.googleapis.com/v1beta/{}:{}?key={}".format( - _gemini_model_name, endpoint, gemini_api_key - ) elif mode == "count_tokens": endpoint = "countTokens" - url = "https://generativelanguage.googleapis.com/v1beta/{}:{}?key={}".format( - _gemini_model_name, endpoint, gemini_api_key - ) elif mode == "image_generation": raise ValueError( "LiteLLM's `gemini/` route does not support image generation yet. Let us know if you need this feature by opening an issue at https://github.com/BerriAI/litellm/issues" @@ -381,6 +367,21 @@ def _get_gemini_url( else: raise ValueError(f"Unsupported mode: {mode}") + base_url = "https://generativelanguage.googleapis.com/{}/{}:{}".format( + api_version if mode == "chat" else "v1beta", _gemini_model_name, endpoint + ) + + params = [] + if gemini_api_key and not gemini_oauth_token: + params.append(f"key={gemini_api_key}") + if stream: + params.append("alt=sse") + + if params: + url = f"{base_url}?{'&'.join(params)}" + else: + url = base_url + return url, endpoint diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index 1a29ba82eac..2ffe40d6185 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -394,12 +394,30 @@ class VertexBase: "Model parameter is required for Gemini custom API base URLs" ) url = "{}/models/{}:{}".format(api_base, model, endpoint) + + gemini_auth_data = None if gemini_api_key is None: - raise ValueError( - "Missing gemini_api_key, please set `GEMINI_API_KEY`" + from litellm.llms.gemini.common_utils import ( + get_gemini_oauth_token, ) - if gemini_api_key is not None: + + gemini_auth_data = get_gemini_oauth_token() + gemini_oauth_token = ( + gemini_auth_data.get("token") if gemini_auth_data else None + ) + + if gemini_oauth_token: + auth_header = {"Authorization": f"Bearer {gemini_oauth_token}"} + if gemini_auth_data and gemini_auth_data.get("project_id"): + auth_header["x-goog-user-project"] = gemini_auth_data[ + "project_id" + ] + elif gemini_api_key is not None: auth_header = {"x-goog-api-key": gemini_api_key} # type: ignore[assignment] + else: + raise ValueError( + "Missing gemini_api_key. Please set `GEMINI_API_KEY` or `GEMINI_OAUTH_TOKEN`." + ) else: # For Vertex AI if use_psc_endpoint_format: @@ -453,13 +471,30 @@ class VertexBase: """ version: Optional[Literal["v1beta1", "v1"]] = None if custom_llm_provider == "gemini": + gemini_auth_data = None + if gemini_api_key is None: + from litellm.llms.gemini.common_utils import get_gemini_oauth_token + + gemini_auth_data = get_gemini_oauth_token() + gemini_oauth_token = ( + gemini_auth_data.get("token") if gemini_auth_data else None + ) + url, endpoint = _get_gemini_url( mode=mode, model=model, stream=stream, gemini_api_key=gemini_api_key, + gemini_oauth_token=gemini_oauth_token, ) - auth_header = None # this field is not used for gemin + if gemini_oauth_token: + auth_header = {"Authorization": f"Bearer {gemini_oauth_token}"} + if gemini_auth_data and gemini_auth_data.get("project_id"): + auth_header["x-goog-user-project"] = gemini_auth_data["project_id"] + else: + auth_header = ( + None # this field is not used for gemini when using api key + ) else: vertex_location = self.get_vertex_region( vertex_region=vertex_location, diff --git a/litellm/main.py b/litellm/main.py index 81319bc432f..41140e0cae8 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -101,6 +101,11 @@ from litellm.llms.cohere.common_utils import CohereModelInfo from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config from litellm.llms.openai_like.json_loader import JSONProviderRegistry +from litellm.llms.google_code_assist.chat import GoogleCodeAssistChat +from litellm.llms.gemini.fallback_handler import ( + run_gemini_acompletion_with_code_assist_fallback, + run_gemini_completion_with_code_assist_fallback, +) from litellm.llms.vertex_ai.common_utils import ( VertexAIModelRoute, get_vertex_ai_model_route, @@ -197,7 +202,9 @@ from .llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from .llms.custom_llm import CustomLLM, custom_chat_llm_router from .llms.databricks.embed.handler import DatabricksEmbeddingHandler from .llms.deprecated_providers import aleph_alpha, palm -from .llms.gemini.common_utils import get_api_key_from_env +from .llms.gemini.common_utils import ( + get_api_key_from_env, +) from .llms.groq.chat.handler import GroqChatCompletion from .llms.heroku.chat.transformation import HerokuChatConfig from .llms.huggingface.embedding.handler import HuggingFaceEmbedding @@ -3436,27 +3443,74 @@ def completion( # type: ignore # noqa: PLR0915 api_base = api_base or litellm.api_base or get_secret("GEMINI_API_BASE") new_params = safe_deep_copy(optional_params or {}) - response = vertex_chat_completion.completion( # type: ignore - model=model, - messages=messages, - model_response=model_response, - print_verbose=print_verbose, - optional_params=new_params, - litellm_params=litellm_params, # type: ignore - logger_fn=logger_fn, - encoding=_get_encoding(), - vertex_location=vertex_ai_location, - vertex_project=vertex_ai_project, - vertex_credentials=vertex_credentials, - gemini_api_key=gemini_api_key, - logging_obj=logging, - acompletion=acompletion, - timeout=timeout, - custom_llm_provider=custom_llm_provider, # type: ignore - client=client, - api_base=api_base, - extra_headers=headers, - ) + if acompletion is True: + response = run_gemini_acompletion_with_code_assist_fallback( + primary_call=vertex_chat_completion.completion( # type: ignore + model=model, + messages=messages, + model_response=model_response, + print_verbose=print_verbose, + optional_params=new_params, + litellm_params=litellm_params, # type: ignore + logger_fn=logger_fn, + encoding=_get_encoding(), + vertex_location=vertex_ai_location, + vertex_project=vertex_ai_project, + vertex_credentials=vertex_credentials, + gemini_api_key=gemini_api_key, + logging_obj=logging, + acompletion=True, + timeout=timeout, + custom_llm_provider=custom_llm_provider, # type: ignore + client=client, + api_base=api_base, + extra_headers=headers, + ), + fallback_kwargs={ + "model": model, + "messages": messages, + "model_response": model_response, + "print_verbose": print_verbose, + "optional_params": optional_params, + "litellm_params": litellm_params, # type: ignore + "logging_obj": logging, + "logger_fn": logger_fn, + }, + ) + else: + response = run_gemini_completion_with_code_assist_fallback( + primary_call=lambda: vertex_chat_completion.completion( # type: ignore + model=model, + messages=messages, + model_response=model_response, + print_verbose=print_verbose, + optional_params=new_params, + litellm_params=litellm_params, # type: ignore + logger_fn=logger_fn, + encoding=_get_encoding(), + vertex_location=vertex_ai_location, + vertex_project=vertex_ai_project, + vertex_credentials=vertex_credentials, + gemini_api_key=gemini_api_key, + logging_obj=logging, + acompletion=False, + timeout=timeout, + custom_llm_provider=custom_llm_provider, # type: ignore + client=client, + api_base=api_base, + extra_headers=headers, + ), + fallback_kwargs={ + "model": model, + "messages": messages, + "model_response": model_response, + "print_verbose": print_verbose, + "optional_params": optional_params, + "litellm_params": litellm_params, # type: ignore + "logging_obj": logging, + "logger_fn": logger_fn, + }, + ) elif custom_llm_provider == "vertex_ai": vertex_ai_project = ( @@ -3632,6 +3686,33 @@ def completion( # type: ignore # noqa: PLR0915 ) return response response = model_response + + elif custom_llm_provider == "google_code_assist": + google_code_assist_chat = GoogleCodeAssistChat() + if acompletion is True: + response = google_code_assist_chat.acompletion( + model=model, + messages=messages, + model_response=model_response, + print_verbose=print_verbose, + optional_params=optional_params, + litellm_params=litellm_params, # type: ignore + logging_obj=logging, + logger_fn=logger_fn, + ) + else: + model_response = google_code_assist_chat.completion( + model=model, + messages=messages, + model_response=model_response, + print_verbose=print_verbose, + optional_params=optional_params, + litellm_params=litellm_params, # type: ignore + logging_obj=logging, + logger_fn=logger_fn, + ) + response = model_response + elif custom_llm_provider == "predibase": tenant_id = ( optional_params.pop("tenant_id", None) @@ -7533,9 +7614,7 @@ def stream_chunk_builder( # noqa: PLR0915 # the final chunk. all_annotations: list = [] for ac in annotation_chunks: - all_annotations.extend( - ac["choices"][0]["delta"]["annotations"] - ) + all_annotations.extend(ac["choices"][0]["delta"]["annotations"]) response["choices"][0]["message"]["annotations"] = all_annotations audio_chunks = [ diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 880b0b39720..b83e5b3cd63 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -29,6 +29,18 @@ litellm-proxy --version litellm-proxy -v ``` +## Gemini OAuth Login + +Authenticate locally for Gemini OAuth-based providers (for example `google_code_assist/*`): + +```bash +export GEMINI_OAUTH_CLIENT_ID="" +export GEMINI_OAUTH_CLIENT_SECRET="" +litellm-proxy gemini login +``` + +This starts a browser-based loopback OAuth flow and stores credentials for reuse. + ## Commands ### Models Management diff --git a/litellm/proxy/client/cli/commands/gemini.py b/litellm/proxy/client/cli/commands/gemini.py new file mode 100644 index 00000000000..f88e593fe53 --- /dev/null +++ b/litellm/proxy/client/cli/commands/gemini.py @@ -0,0 +1,19 @@ +import click +from litellm.llms.gemini.authenticator import GeminiAuthenticator + + +@click.group(name="gemini") +def gemini(): + """Gemini-specific management commands""" + pass + + +@gemini.command(name="login") +def login(): + """Login to Gemini using OAuth Device Flow (Loopback)""" + try: + auth = GeminiAuthenticator() + auth.get_token() + click.echo("✅ Successfully authenticated with Gemini.") + except Exception as e: + raise click.ClickException(f"Gemini authentication failed: {e}") diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py index 744acf38382..36c62ca3722 100644 --- a/litellm/proxy/client/cli/main.py +++ b/litellm/proxy/client/cli/main.py @@ -12,6 +12,7 @@ from .commands.chat import chat from .commands.credentials import credentials from .commands.http import http from .commands.keys import keys +from .commands.gemini import gemini # local imports from .commands.models import models @@ -105,6 +106,7 @@ cli.add_command(chat) cli.add_command(http) # Add the keys command group cli.add_command(keys) +cli.add_command(gemini) # Add the teams command group cli.add_command(teams) # Add the users command group diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 38425c7ac4a..e7e95dc3149 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3151,6 +3151,7 @@ class LlmProviders(str, Enum): VERTEX_AI = "vertex_ai" VERTEX_AI_BETA = "vertex_ai_beta" GEMINI = "gemini" + GOOGLE_CODE_ASSIST = "google_code_assist" AI21 = "ai21" BASETEN = "baseten" BLACK_FOREST_LABS = "black_forest_labs" diff --git a/litellm/utils.py b/litellm/utils.py index 81d749ab821..71279e23f71 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7981,6 +7981,10 @@ class ProviderConfigManager: LlmProviders.COMETAPI: (lambda: litellm.CometAPIConfig(), False), LlmProviders.DATAROBOT: (lambda: litellm.DataRobotConfig(), False), LlmProviders.GEMINI: (lambda: litellm.GoogleAIStudioGeminiConfig(), False), + LlmProviders.GOOGLE_CODE_ASSIST: ( + lambda: ProviderConfigManager._get_google_code_assist_config(), + False, + ), LlmProviders.AI21: (lambda: litellm.AI21ChatConfig(), False), LlmProviders.AI21_CHAT: (lambda: litellm.AI21ChatConfig(), False), LlmProviders.AZURE_TEXT: (lambda: litellm.AzureOpenAITextConfig(), False), @@ -8100,6 +8104,15 @@ class ProviderConfigManager: return LangGraphConfig() + @staticmethod + def _get_google_code_assist_config() -> BaseConfig: + """Get Google Code Assist config.""" + from litellm.llms.google_code_assist.transformation import ( + GoogleCodeAssistConfig, + ) + + return GoogleCodeAssistConfig() + @staticmethod def get_provider_chat_config( # noqa: PLR0915 model: str, provider: LlmProviders diff --git a/poetry.lock b/poetry.lock index 591d0c270e4..be87e51f801 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "a2a-sdk" @@ -7,11 +7,11 @@ description = "A2A Python SDK" optional = false python-versions = ">=3.10" groups = ["main", "proxy-dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "a2a_sdk-0.3.22-py3-none-any.whl", hash = "sha256:b98701135bb90b0ff85d35f31533b6b7a299bf810658c1c65f3814a6c15ea385"}, {file = "a2a_sdk-0.3.22.tar.gz", hash = "sha256:77a5694bfc4f26679c11b70c7f1062522206d430b34bc1215cfbb1eba67b7e7d"}, ] +markers = {main = "python_version >= \"3.10\" and extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [package.dependencies] google-api-core = ">=1.26.0" @@ -385,6 +385,7 @@ files = [ {file = "azure_core-1.36.0-py3-none-any.whl", hash = "sha256:fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b"}, {file = "azure_core-1.36.0.tar.gz", hash = "sha256:22e5605e6d0bf1d229726af56d9e92bc37b6e726b141a18be0b4d424131741b7"}, ] +markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""} [package.dependencies] requests = ">=2.21.0" @@ -405,6 +406,7 @@ files = [ {file = "azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651"}, {file = "azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456"}, ] +markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""} [package.dependencies] azure-core = ">=1.31.0" @@ -598,7 +600,7 @@ files = [ {file = "cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace"}, {file = "cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [[package]] name = "certifi" @@ -705,7 +707,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] -markers = {main = "platform_python_implementation != \"PyPy\" or extra == \"proxy\"", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""} +markers = {main = "(platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -1055,6 +1057,7 @@ files = [ {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, ] +markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\") or extra == \"proxy\" or extra == \"extra-proxy\""} [package.dependencies] cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} @@ -1837,11 +1840,11 @@ description = "Google API client core library" optional = false python-versions = ">=3.7" groups = ["main", "proxy-dev"] -markers = "python_version >= \"3.14\"" files = [ {file = "google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7"}, {file = "google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300"}, ] +markers = {main = "python_version >= \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.14\""} [package.dependencies] google-auth = ">=2.14.1,<3.0.0" @@ -1869,7 +1872,7 @@ files = [ {file = "google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c"}, {file = "google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8"}, ] -markers = {main = "(python_version >= \"3.10\" or extra == \"google\" or extra == \"extra-proxy\") and python_version < \"3.14\"", proxy-dev = "python_version >= \"3.10\" and python_version < \"3.14\""} +markers = {main = "python_version < \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.10\" and python_version < \"3.14\""} [package.dependencies] google-auth = ">=2.14.1,<3.0.0" @@ -1906,7 +1909,7 @@ files = [ {file = "google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16"}, {file = "google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [package.dependencies] cachetools = ">=2.0.0,<7.0" @@ -2078,11 +2081,11 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" -grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" -proto-plus = ">=1.22.3,<2.0.0dev" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0.dev0" +proto-plus = ">=1.22.3,<2.0.0.dev0" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" [[package]] name = "google-cloud-resource-manager" @@ -2264,7 +2267,7 @@ files = [ {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\") or extra == \"google\" or extra == \"extra-proxy\""} [package.dependencies] grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} @@ -2673,11 +2676,11 @@ description = "Consume Server-Sent Event (SSE) messages with HTTPX." optional = false python-versions = ">=3.9" groups = ["main", "proxy-dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, ] +markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\")", proxy-dev = "python_version >= \"3.10\""} [[package]] name = "huey" @@ -3042,7 +3045,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -3219,15 +3222,15 @@ files = [ [[package]] name = "litellm-proxy-extras" -version = "0.4.56" +version = "0.4.57" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." optional = true python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" groups = ["main"] markers = "extra == \"proxy\"" files = [ - {file = "litellm_proxy_extras-0.4.56-py3-none-any.whl", hash = "sha256:52dbe3b5358c790e77e12f1ec5ef8e7508b383c2aaf41299750b6fb400908ee7"}, - {file = "litellm_proxy_extras-0.4.56.tar.gz", hash = "sha256:63ad59baa0defccc5c929cfd933ee7e32a6614b0fc5fa0fc45a12d7608e33f08"}, + {file = "litellm_proxy_extras-0.4.57-py3-none-any.whl", hash = "sha256:04538223cd80318a72d70c6e10f701598e58c763368296a6503c674c92fbdb62"}, + {file = "litellm_proxy_extras-0.4.57.tar.gz", hash = "sha256:ef9b95dc42237614216833bd5d46ebf9dea1caa5ea14ea1a66d7f7842b224ec2"}, ] [[package]] @@ -3713,6 +3716,7 @@ files = [ {file = "msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1"}, {file = "msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f"}, ] +markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""} [package.dependencies] cryptography = ">=2.5,<49" @@ -3733,6 +3737,7 @@ files = [ {file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"}, {file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"}, ] +markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""} [package.dependencies] msal = ">=1.29,<2" @@ -3983,6 +3988,7 @@ files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +markers = {main = "extra == \"extra-proxy\""} [[package]] name = "numpy" @@ -4105,7 +4111,7 @@ files = [ {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"}, {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"}, ] -markers = {main = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""} [package.dependencies] importlib-metadata = ">=6.0,<8.8.0" @@ -4220,7 +4226,7 @@ files = [ {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"}, {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"}, ] -markers = {main = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""} [package.dependencies] opentelemetry-api = "1.39.1" @@ -4238,7 +4244,7 @@ files = [ {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"}, {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"}, ] -markers = {main = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""} [package.dependencies] opentelemetry-api = "1.39.1" @@ -4722,6 +4728,7 @@ files = [ {file = "prisma-0.11.0-py3-none-any.whl", hash = "sha256:22bb869e59a2968b99f3483bb417717273ffbc569fd1e9ceed95e5614cbaf53a"}, {file = "prisma-0.11.0.tar.gz", hash = "sha256:3f2f2fd2361e1ec5ff655f2a04c7860c2f2a5bc4c91f78ca9c5c6349735bf693"}, ] +markers = {main = "extra == \"extra-proxy\""} [package.dependencies] click = ">=7.1.2" @@ -4895,7 +4902,7 @@ files = [ {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [package.dependencies] protobuf = ">=3.19.0,<7.0.0" @@ -4923,7 +4930,7 @@ files = [ {file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"}, {file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\""} [[package]] name = "psutil" @@ -5083,7 +5090,7 @@ files = [ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [[package]] name = "pyasn1-modules" @@ -5096,7 +5103,7 @@ files = [ {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [package.dependencies] pyasn1 = ">=0.6.1,<0.7.0" @@ -5124,7 +5131,7 @@ files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] -markers = {main = "implementation_name != \"PyPy\" and (platform_python_implementation != \"PyPy\" or extra == \"proxy\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""} +markers = {main = "implementation_name != \"PyPy\" and (platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""} [[package]] name = "pydantic" @@ -5347,6 +5354,7 @@ files = [ {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, ] +markers = {main = "(python_version <= \"3.13\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"extra-proxy\" or extra == \"proxy\")"} [package.dependencies] cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} @@ -6290,7 +6298,7 @@ files = [ {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [package.dependencies] pyasn1 = ">=0.1.3" @@ -6336,10 +6344,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "scikit-learn" @@ -6492,9 +6500,9 @@ tornado = ">=6.4.2,<7" urllib3 = ">=1.26,<3" [package.extras] -all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.00)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"] +all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.0)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"] bedrock = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)"] -cohere = ["cohere (>=5.9.4,<6.00)"] +cohere = ["cohere (>=5.9.4,<6.0)"] dev = ["dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "ipykernel (>=6.25.0,<7)", "mypy (>=1.7.1,<2)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"] docs = ["pydoc-markdown (>=4.8.2) ; python_version < \"3.12\""] fastembed = ["fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\""] @@ -7222,6 +7230,7 @@ files = [ {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, ] +markers = {main = "extra == \"extra-proxy\""} [[package]] name = "tornado" @@ -7994,4 +8003,4 @@ utils = ["numpydoc"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0" -content-hash = "1f3bbf967451633fb6290ba88980bdf4fbf83420024b14e862d1da717d903684" +content-hash = "22cdc8096e0c8296827734393f5ab6e66088f397b5295caa1d277466d1fde1e8" diff --git a/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py b/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py new file mode 100644 index 00000000000..61bda83eb21 --- /dev/null +++ b/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py @@ -0,0 +1,127 @@ +import pytest +from unittest.mock import MagicMock, patch +import httpx +import json +from litellm.llms.google_code_assist.chat import GoogleCodeAssistChat +from litellm.types.utils import ModelResponse + + +class TestGoogleCodeAssist: + @patch("litellm.llms.google_code_assist.chat._get_httpx_client") + @patch("litellm.llms.gemini.common_utils.get_gemini_oauth_token") + def test_completion_basic(self, mock_get_token, mock_get_client): + """ + Test basic completion with mocked handshake and API call. + """ + mock_get_token.return_value = { + "token": "test-token", + "project_id": "test-project", + } + + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + # Mock handshake response + handshake_data = {"cloudaicompanionProject": "final-project"} + mock_handshake_resp = httpx.Response( + status_code=200, + content=json.dumps(handshake_data).encode(), + request=httpx.Request("POST", "https://handshake"), + ) + + # Mock completion response + completion_data = { + "response": { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "Hello world"}], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 2, + "totalTokenCount": 7, + }, + } + } + mock_completion_resp = httpx.Response( + status_code=200, + content=json.dumps(completion_data).encode(), + request=httpx.Request("POST", "https://completion"), + ) + + mock_client.post.side_effect = [mock_handshake_resp, mock_completion_resp] + + handler = GoogleCodeAssistChat() + response = handler.completion( + model="google_code_assist/gemini-1.5-flash", + messages=[{"role": "user", "content": "hi"}], + model_response=ModelResponse(), + print_verbose=False, + logging_obj=MagicMock(), + optional_params={}, + litellm_params={}, + ) + + assert response.choices[0].message.content == "Hello world" + assert response.usage.total_tokens == 7 + + @pytest.mark.asyncio + @patch("litellm.llms.google_code_assist.chat.AsyncHTTPHandler.post") + @patch("litellm.llms.gemini.common_utils.get_gemini_oauth_token") + async def test_acompletion_basic(self, mock_get_token, mock_async_post): + """ + Test async completion. + """ + mock_get_token.return_value = {"token": "test-token"} + + # Handshake (async) + handshake_data = {"cloudaicompanionProject": "final-project"} + mock_handshake_resp = httpx.Response( + status_code=200, + content=json.dumps(handshake_data).encode(), + request=httpx.Request("POST", "https://handshake"), + ) + + # Completion (async) + completion_data = { + "response": { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "Async success"}], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 2, + "totalTokenCount": 7, + }, + } + } + mock_completion_resp = httpx.Response( + status_code=200, + content=json.dumps(completion_data).encode(), + request=httpx.Request("POST", "https://completion"), + ) + mock_async_post.side_effect = [mock_handshake_resp, mock_completion_resp] + + handler = GoogleCodeAssistChat() + response = await handler.acompletion( + model="google_code_assist/gemini-1.5-flash", + messages=[{"role": "user", "content": "hi"}], + model_response=ModelResponse(), + print_verbose=False, + logging_obj=MagicMock(), + optional_params={}, + litellm_params={}, + ) + + assert response.choices[0].message.content == "Async success" From 3c143336f724095c78a717d91d3f3f84d90a635b Mon Sep 17 00:00:00 2001 From: balazss Date: Tue, 17 Mar 2026 20:41:21 -0700 Subject: [PATCH 02/13] fix(gemini): close async handler and harden scope fallback detection --- litellm/llms/gemini/common_utils.py | 74 +++++++- litellm/llms/google_code_assist/chat.py | 53 +++--- .../llms/google_code_assist/transformation.py | 2 +- .../llms/gemini/test_gemini_common_utils.py | 163 +++++++++++------- .../test_google_code_assist.py | 11 +- 5 files changed, 212 insertions(+), 91 deletions(-) diff --git a/litellm/llms/gemini/common_utils.py b/litellm/llms/gemini/common_utils.py index f93a105f09f..a1cf0564f68 100644 --- a/litellm/llms/gemini/common_utils.py +++ b/litellm/llms/gemini/common_utils.py @@ -1,5 +1,6 @@ import base64 import datetime +import json from typing import Any, Dict, List, Optional, Union import httpx @@ -159,7 +160,78 @@ def should_fallback_to_google_code_assist(error: Exception) -> bool: """ Returns True if the error indicates missing OAuth scope for Gemini calls. """ - return "ACCESS_TOKEN_SCOPE_INSUFFICIENT" in str(error) + + def _iter_exception_chain(exc: BaseException): + seen = set() + current: Optional[BaseException] = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + yield current + current = current.__cause__ or current.__context__ + + def _contains_scope_code(value: Any) -> bool: + if isinstance(value, str): + return "ACCESS_TOKEN_SCOPE_INSUFFICIENT" in value + if isinstance(value, dict): + for v in value.values(): + if _contains_scope_code(v): + return True + return False + if isinstance(value, list): + for item in value: + if _contains_scope_code(item): + return True + return False + return False + + def _extract_json_payload_from_response(response: Any) -> Optional[dict]: + if response is None: + return None + try: + payload = response.json() + if isinstance(payload, dict): + return payload + except Exception: + pass + try: + text = getattr(response, "text", None) + if isinstance(text, str): + payload = json.loads(text) + if isinstance(payload, dict): + return payload + except Exception: + pass + return None + + for exc in _iter_exception_chain(error): + if isinstance(exc, httpx.HTTPStatusError): + response = getattr(exc, "response", None) + if response is None or getattr(response, "status_code", None) != 403: + continue + + payload = _extract_json_payload_from_response(response) + if payload and _contains_scope_code(payload): + return True + continue + + status_code = getattr(exc, "status_code", None) + if status_code != 403: + continue + + body = getattr(exc, "body", None) + if body and _contains_scope_code(body): + return True + + response = getattr(exc, "response", None) + payload = _extract_json_payload_from_response(response) + if payload and _contains_scope_code(payload): + return True + + message = getattr(exc, "message", None) + if message and _contains_scope_code(message): + return True + + return False def get_gemini_oauth_token() -> Optional[dict]: # noqa: PLR0915 diff --git a/litellm/llms/google_code_assist/chat.py b/litellm/llms/google_code_assist/chat.py index af6fa7abc01..7bb74cb4a54 100644 --- a/litellm/llms/google_code_assist/chat.py +++ b/litellm/llms/google_code_assist/chat.py @@ -113,32 +113,39 @@ class GoogleCodeAssistChat: initial_project_id = gemini_auth_data.get("project_id") async_handler = AsyncHTTPHandler() + try: + final_project_id = await self._ahandle_handshake( + async_handler, token, initial_project_id + ) + litellm_params["google_code_assist_project"] = final_project_id - final_project_id = await self._ahandle_handshake( - async_handler, token, initial_project_id - ) - litellm_params["google_code_assist_project"] = final_project_id + data = self.config.transform_request( + model, messages, optional_params, litellm_params + ) + url = "https://cloudcode-pa.googleapis.com/v1internal:generateContent" + headers = self._get_headers(token) - data = self.config.transform_request( - model, messages, optional_params, litellm_params - ) - url = "https://cloudcode-pa.googleapis.com/v1internal:generateContent" - headers = self._get_headers(token) + response = await async_handler.post(url=url, headers=headers, json=data) + response.raise_for_status() - response = await async_handler.post(url=url, headers=headers, json=data) - response.raise_for_status() - - return self.config.transform_response( - model=model, - raw_response=response, - model_response=model_response, - logging_obj=logging_obj, - request_data=data, - messages=messages, - optional_params=optional_params, - litellm_params=litellm_params, - encoding=None, - ) + return self.config.transform_response( + model=model, + raw_response=response, + model_response=model_response, + logging_obj=logging_obj, + request_data=data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=None, + ) + finally: + try: + await async_handler.close() + except Exception as close_error: + verbose_logger.debug( + f"Failed to close Google Code Assist async HTTP handler: {close_error}" + ) except Exception as e: raise self._handle_error(e) diff --git a/litellm/llms/google_code_assist/transformation.py b/litellm/llms/google_code_assist/transformation.py index 5de7dd725ed..b58c7ed780d 100644 --- a/litellm/llms/google_code_assist/transformation.py +++ b/litellm/llms/google_code_assist/transformation.py @@ -148,7 +148,7 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): vertex_request["generationConfig"] = generation_config # 3. Wrap in Code Assist envelope (matches verified gemini-cli structure) - user_prompt_id = f"litellm-{uuid.uuid4()}"[:13] + user_prompt_id = f"litellm-{uuid.uuid4()}" model_name = model.split("/")[-1] ca_request = { diff --git a/tests/test_litellm/llms/gemini/test_gemini_common_utils.py b/tests/test_litellm/llms/gemini/test_gemini_common_utils.py index c31ff308c61..92dde78c2f3 100644 --- a/tests/test_litellm/llms/gemini/test_gemini_common_utils.py +++ b/tests/test_litellm/llms/gemini/test_gemini_common_utils.py @@ -1,8 +1,13 @@ from unittest.mock import AsyncMock, patch +import httpx import pytest -from litellm.llms.gemini.common_utils import GeminiModelInfo, GoogleAIStudioTokenCounter +from litellm.llms.gemini.common_utils import ( + GeminiModelInfo, + GoogleAIStudioTokenCounter, + should_fallback_to_google_code_assist, +) class TestGeminiModelInfo: @@ -94,17 +99,23 @@ class TestGoogleAIStudioTokenCounter: def test_should_use_token_counting_api(self): """Test should_use_token_counting_api method with different provider values""" from litellm.types.utils import LlmProviders - + token_counter = GoogleAIStudioTokenCounter() - + # Test with gemini provider - should return True - assert token_counter.should_use_token_counting_api(LlmProviders.GEMINI.value) is True - + assert ( + token_counter.should_use_token_counting_api(LlmProviders.GEMINI.value) + is True + ) + # Test with other providers - should return False - assert token_counter.should_use_token_counting_api(LlmProviders.OPENAI.value) is False + assert ( + token_counter.should_use_token_counting_api(LlmProviders.OPENAI.value) + is False + ) assert token_counter.should_use_token_counting_api("anthropic") is False assert token_counter.should_use_token_counting_api("vertex_ai") is False - + # Test with None - should return False assert token_counter.should_use_token_counting_api(None) is False @@ -112,39 +123,36 @@ class TestGoogleAIStudioTokenCounter: async def test_count_tokens(self): """Test count_tokens method with mocked API response""" from litellm.types.utils import TokenCountResponse - + token_counter = GoogleAIStudioTokenCounter() - + # Mock the GoogleAIStudioTokenCounter from handler module mock_response = { "totalTokens": 31, "totalBillableCharacters": 96, - "promptTokensDetails": [ - { - "modality": "TEXT", - "tokenCount": 31 - } - ] + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 31}], } - - with patch('litellm.llms.gemini.count_tokens.handler.GoogleAIStudioTokenCounter.acount_tokens', - new_callable=AsyncMock) as mock_acount_tokens: + + with patch( + "litellm.llms.gemini.count_tokens.handler.GoogleAIStudioTokenCounter.acount_tokens", + new_callable=AsyncMock, + ) as mock_acount_tokens: mock_acount_tokens.return_value = mock_response - + # Test data model_to_use = "gemini-1.5-flash" contents = [{"parts": [{"text": "Hello world"}]}] request_model = "gemini/gemini-1.5-flash" - + # Call the method result = await token_counter.count_tokens( model_to_use=model_to_use, messages=None, contents=contents, deployment=None, - request_model=request_model + request_model=request_model, ) - + # Verify the result assert result is not None assert isinstance(result, TokenCountResponse) @@ -152,29 +160,21 @@ class TestGoogleAIStudioTokenCounter: assert result.request_model == request_model assert result.model_used == model_to_use assert result.original_response == mock_response - + # Verify the mock was called correctly mock_acount_tokens.assert_called_once_with( - model=model_to_use, - contents=contents + model=model_to_use, contents=contents ) def test_clean_contents_for_gemini_api_removes_id_field(self): """Test that _clean_contents_for_gemini_api removes unsupported 'id' field from function responses""" from litellm.llms.gemini.count_tokens.handler import GoogleAIStudioTokenCounter - + token_counter = GoogleAIStudioTokenCounter() - + # Test contents with function response containing 'id' field (camelCase) contents_with_id = [ - { - "parts": [ - { - "text": "Hello world" - } - ], - "role": "user" - }, + {"parts": [{"text": "Hello world"}], "role": "user"}, { "parts": [ { @@ -183,56 +183,91 @@ class TestGoogleAIStudioTokenCounter: "name": "read_many_files", "response": { "output": "No files matching the criteria were found or all were skipped." - } + }, } } ], - "role": "user" - } + "role": "user", + }, ] - + # Clean the contents - cleaned_contents = token_counter._clean_contents_for_gemini_api(contents_with_id) - + cleaned_contents = token_counter._clean_contents_for_gemini_api( + contents_with_id + ) + # Verify the 'id' field was removed function_response = cleaned_contents[1]["parts"][0]["functionResponse"] assert "id" not in function_response assert "name" in function_response assert "response" in function_response assert function_response["name"] == "read_many_files" - assert function_response["response"]["output"] == "No files matching the criteria were found or all were skipped." - + assert ( + function_response["response"]["output"] + == "No files matching the criteria were found or all were skipped." + ) def test_clean_contents_for_gemini_api_preserves_other_fields(self): """Test that _clean_contents_for_gemini_api preserves other fields and structure""" from litellm.llms.gemini.count_tokens.handler import GoogleAIStudioTokenCounter - + token_counter = GoogleAIStudioTokenCounter() - + # Test contents without function responses contents_without_function_response = [ - { - "parts": [ - { - "text": "This is a regular message" - } - ], - "role": "user" - }, - { - "parts": [ - { - "text": "This is a model response" - } - ], - "role": "model" - } + {"parts": [{"text": "This is a regular message"}], "role": "user"}, + {"parts": [{"text": "This is a model response"}], "role": "model"}, ] - + # Clean the contents - cleaned_contents = token_counter._clean_contents_for_gemini_api(contents_without_function_response) - + cleaned_contents = token_counter._clean_contents_for_gemini_api( + contents_without_function_response + ) + # Verify the contents are unchanged assert cleaned_contents == contents_without_function_response +class TestGeminiFallbackDetection: + def test_should_fallback_to_google_code_assist_for_structured_403_scope_error(self): + request = httpx.Request( + "POST", "https://generativelanguage.googleapis.com/test" + ) + response = httpx.Response( + status_code=403, + json={ + "error": { + "code": 403, + "message": "Request had insufficient authentication scopes.", + "status": "PERMISSION_DENIED", + "details": [{"reason": "ACCESS_TOKEN_SCOPE_INSUFFICIENT"}], + } + }, + request=request, + ) + err = httpx.HTTPStatusError( + "403 Client Error: Forbidden for url", request=request, response=response + ) + + assert should_fallback_to_google_code_assist(err) is True + + def test_should_not_fallback_to_google_code_assist_for_non_403_scope_string(self): + request = httpx.Request( + "POST", "https://generativelanguage.googleapis.com/test" + ) + response = httpx.Response( + status_code=500, + json={ + "error": { + "message": "ACCESS_TOKEN_SCOPE_INSUFFICIENT", + } + }, + request=request, + ) + err = httpx.HTTPStatusError( + "500 Server Error: Internal Server Error for url", + request=request, + response=response, + ) + + assert should_fallback_to_google_code_assist(err) is False diff --git a/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py b/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py index 61bda83eb21..5340443775e 100644 --- a/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py +++ b/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py @@ -1,5 +1,5 @@ import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import httpx import json from litellm.llms.google_code_assist.chat import GoogleCodeAssistChat @@ -72,8 +72,14 @@ class TestGoogleCodeAssist: @pytest.mark.asyncio @patch("litellm.llms.google_code_assist.chat.AsyncHTTPHandler.post") + @patch( + "litellm.llms.google_code_assist.chat.AsyncHTTPHandler.close", + new_callable=AsyncMock, + ) @patch("litellm.llms.gemini.common_utils.get_gemini_oauth_token") - async def test_acompletion_basic(self, mock_get_token, mock_async_post): + async def test_acompletion_basic( + self, mock_get_token, mock_async_close, mock_async_post + ): """ Test async completion. """ @@ -125,3 +131,4 @@ class TestGoogleCodeAssist: ) assert response.choices[0].message.content == "Async success" + mock_async_close.assert_awaited_once() From 4079922c1c946b430ffa00e2298634796148b29d Mon Sep 17 00:00:00 2001 From: balazss Date: Tue, 17 Mar 2026 21:20:07 -0700 Subject: [PATCH 03/13] fix(gemini): gate auto-fallback and harden oauth/token paths --- litellm/__init__.py | 16 +- litellm/llms/gemini/authenticator.py | 5 +- litellm/llms/gemini/common_utils.py | 6 +- litellm/llms/gemini/fallback_handler.py | 8 + litellm/llms/vertex_ai/common_utils.py | 2 +- litellm/main.py | 2 + .../llms/gemini/test_fallback_handler.py | 57 +++++ .../vertex_ai/test_vertex_ai_common_utils.py | 220 +++++++++++------- 8 files changed, 220 insertions(+), 96 deletions(-) create mode 100644 tests/test_litellm/llms/gemini/test_fallback_handler.py diff --git a/litellm/__init__.py b/litellm/__init__.py index 51c66838613..52f344fb797 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -433,6 +433,10 @@ default_fallbacks: Optional[List] = None fallbacks: Optional[List] = None context_window_fallbacks: Optional[List] = None content_policy_fallbacks: Optional[List] = None +# Backward-compatible default: do not silently reroute gemini/* calls unless explicitly enabled. +auto_fallback_to_google_code_assist: bool = os.getenv( + "LITELLM_AUTO_FALLBACK_TO_GOOGLE_CODE_ASSIST", "false" +).lower() in ("1", "true", "yes", "on") allowed_fails: int = 3 allow_dynamic_callback_disabling: bool = True num_retries_per_request: Optional[ @@ -1465,9 +1469,15 @@ if TYPE_CHECKING: from .llms.petals.completion.transformation import PetalsConfig as PetalsConfig from .llms.ollama.chat.transformation import OllamaChatConfig as OllamaChatConfig from .llms.ollama.completion.transformation import OllamaConfig as OllamaConfig - from .llms.sagemaker.completion.transformation import SagemakerConfig as SagemakerConfig - from .llms.sagemaker.chat.transformation import SagemakerChatConfig as SagemakerChatConfig - from .llms.sagemaker.nova.transformation import SagemakerNovaConfig as SagemakerNovaConfig + from .llms.sagemaker.completion.transformation import ( + SagemakerConfig as SagemakerConfig, + ) + from .llms.sagemaker.chat.transformation import ( + SagemakerChatConfig as SagemakerChatConfig, + ) + from .llms.sagemaker.nova.transformation import ( + SagemakerNovaConfig as SagemakerNovaConfig, + ) from .llms.cohere.chat.transformation import CohereChatConfig as CohereChatConfig from .llms.anthropic.experimental_pass_through.messages.transformation import ( AnthropicMessagesConfig as AnthropicMessagesConfig, diff --git a/litellm/llms/gemini/authenticator.py b/litellm/llms/gemini/authenticator.py index c5f6aab8a70..bd7d6d0b8dc 100644 --- a/litellm/llms/gemini/authenticator.py +++ b/litellm/llms/gemini/authenticator.py @@ -233,8 +233,9 @@ class GeminiAuthenticator: ) webbrowser.open(auth_url) - # Wait for callback - server.handle_request() + # Wait for callback; browsers may hit non-callback paths first (e.g. /favicon.ico). + while auth_code is None and error is None: + server.handle_request() server.server_close() if error: diff --git a/litellm/llms/gemini/common_utils.py b/litellm/llms/gemini/common_utils.py index a1cf0564f68..99fcedc8a44 100644 --- a/litellm/llms/gemini/common_utils.py +++ b/litellm/llms/gemini/common_utils.py @@ -324,7 +324,11 @@ def get_gemini_oauth_token() -> Optional[dict]: # noqa: PLR0915 continue token = creds_data.get("access_token") if not token and "token" in creds_data: - token = creds_data["token"].get("accessToken") + token_field = creds_data["token"] + if isinstance(token_field, dict): + token = token_field.get("accessToken") + elif isinstance(token_field, str): + token = token_field if token: result = {"token": token} diff --git a/litellm/llms/gemini/fallback_handler.py b/litellm/llms/gemini/fallback_handler.py index 60343b884cd..a59497657e6 100644 --- a/litellm/llms/gemini/fallback_handler.py +++ b/litellm/llms/gemini/fallback_handler.py @@ -8,6 +8,7 @@ from litellm.llms.google_code_assist.chat import GoogleCodeAssistChat async def run_gemini_acompletion_with_code_assist_fallback( primary_call: Awaitable[Any], fallback_kwargs: Dict[str, Any], + auto_fallback_to_google_code_assist: bool = False, ) -> Any: """ Execute Gemini async completion and fallback to Google Code Assist when @@ -16,6 +17,9 @@ async def run_gemini_acompletion_with_code_assist_fallback( try: return await primary_call except Exception as e: + if not auto_fallback_to_google_code_assist: + raise e + if not should_fallback_to_google_code_assist(e): raise e @@ -29,6 +33,7 @@ async def run_gemini_acompletion_with_code_assist_fallback( def run_gemini_completion_with_code_assist_fallback( primary_call: Callable[[], Any], fallback_kwargs: Dict[str, Any], + auto_fallback_to_google_code_assist: bool = False, ) -> Any: """ Execute Gemini sync completion and fallback to Google Code Assist when @@ -37,6 +42,9 @@ def run_gemini_completion_with_code_assist_fallback( try: return primary_call() except Exception as e: + if not auto_fallback_to_google_code_assist: + raise e + if not should_fallback_to_google_code_assist(e): raise e diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 9bd2e1f45f7..754227e8873 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -374,7 +374,7 @@ def _get_gemini_url( params = [] if gemini_api_key and not gemini_oauth_token: params.append(f"key={gemini_api_key}") - if stream: + if mode == "chat" and stream: params.append("alt=sse") if params: diff --git a/litellm/main.py b/litellm/main.py index 41140e0cae8..b59ea1bc801 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -3476,6 +3476,7 @@ def completion( # type: ignore # noqa: PLR0915 "logging_obj": logging, "logger_fn": logger_fn, }, + auto_fallback_to_google_code_assist=litellm.auto_fallback_to_google_code_assist, ) else: response = run_gemini_completion_with_code_assist_fallback( @@ -3510,6 +3511,7 @@ def completion( # type: ignore # noqa: PLR0915 "logging_obj": logging, "logger_fn": logger_fn, }, + auto_fallback_to_google_code_assist=litellm.auto_fallback_to_google_code_assist, ) elif custom_llm_provider == "vertex_ai": diff --git a/tests/test_litellm/llms/gemini/test_fallback_handler.py b/tests/test_litellm/llms/gemini/test_fallback_handler.py new file mode 100644 index 00000000000..544f241a99b --- /dev/null +++ b/tests/test_litellm/llms/gemini/test_fallback_handler.py @@ -0,0 +1,57 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from litellm.llms.gemini.fallback_handler import ( + run_gemini_acompletion_with_code_assist_fallback, + run_gemini_completion_with_code_assist_fallback, +) + + +def test_run_gemini_completion_with_code_assist_fallback_disabled(): + def _raise_scope_error(): + raise Exception("ACCESS_TOKEN_SCOPE_INSUFFICIENT") + + with ( + patch( + "litellm.llms.gemini.fallback_handler.should_fallback_to_google_code_assist", + return_value=True, + ), + patch( + "litellm.llms.gemini.fallback_handler.GoogleCodeAssistChat.completion" + ) as mock_completion, + ): + with pytest.raises(Exception, match="ACCESS_TOKEN_SCOPE_INSUFFICIENT"): + run_gemini_completion_with_code_assist_fallback( + primary_call=_raise_scope_error, + fallback_kwargs={}, + auto_fallback_to_google_code_assist=False, + ) + + mock_completion.assert_not_called() + + +@pytest.mark.asyncio +async def test_run_gemini_acompletion_with_code_assist_fallback_enabled(): + async def _raise_scope_error(): + raise Exception("ACCESS_TOKEN_SCOPE_INSUFFICIENT") + + with ( + patch( + "litellm.llms.gemini.fallback_handler.should_fallback_to_google_code_assist", + return_value=True, + ), + patch( + "litellm.llms.gemini.fallback_handler.GoogleCodeAssistChat.acompletion", + new_callable=AsyncMock, + ) as mock_acompletion, + ): + mock_acompletion.return_value = "fallback-ok" + result = await run_gemini_acompletion_with_code_assist_fallback( + primary_call=_raise_scope_error(), + fallback_kwargs={}, + auto_fallback_to_google_code_assist=True, + ) + + assert result == "fallback-ok" + mock_acompletion.assert_awaited_once() diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py index d483a81a349..79db733e4aa 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py @@ -11,6 +11,7 @@ sys.path.insert( ) # Adds the parent directory to the system path from litellm.llms.vertex_ai.common_utils import ( + _get_gemini_url, _get_vertex_url, convert_anyof_null_to_nullable, get_vertex_location_from_url, @@ -440,7 +441,9 @@ def test_vertex_ai_complex_response_schema(): optional_params = {} v.apply_response_schema_transformation( - value=non_default_params["response_format"], optional_params=optional_params, model="gemini-1.5-pro-preview-0409" + value=non_default_params["response_format"], + optional_params=optional_params, + model="gemini-1.5-pro-preview-0409", ) # Assertions for the transformed schema @@ -558,6 +561,25 @@ def test_get_vertex_url_global_region(stream, expected_endpoint_suffix): assert url == expected_url +def test_get_gemini_url_stream_query_param_only_for_chat_mode(): + chat_url, _ = _get_gemini_url( + mode="chat", + model="gemini-1.5-flash", + stream=True, + gemini_api_key="test-key", + gemini_oauth_token=None, + ) + embedding_url, _ = _get_gemini_url( + mode="embedding", + model="gemini-1.5-flash", + stream=True, + gemini_api_key="test-key", + gemini_oauth_token=None, + ) + + assert "alt=sse" in chat_url + assert "alt=sse" not in embedding_url + @pytest.mark.parametrize( "model_cost_entry, vertex_region, expected_region", @@ -571,9 +593,17 @@ def test_get_vertex_url_global_region(stream, expected_endpoint_suffix): # Model with supported_regions=["us-west2"], no user region -> use "us-west2" ({"supported_regions": ["us-west2"]}, None, "us-west2"), # Model with supported_regions=["us-west2", "us-central1"], user passes supported region -> respect it - ({"supported_regions": ["us-west2", "us-central1"]}, "us-central1", "us-central1"), + ( + {"supported_regions": ["us-west2", "us-central1"]}, + "us-central1", + "us-central1", + ), # Model with supported_regions=["us-west2", "us-central1"], user passes unsupported region -> override - ({"supported_regions": ["us-west2", "us-central1"]}, "europe-west1", "us-west2"), + ( + {"supported_regions": ["us-west2", "us-central1"]}, + "europe-west1", + "us-west2", + ), # No model_cost entry, no user region -> default us-central1 ({}, None, "us-central1"), # No model_cost entry, user specifies region -> use specified region @@ -656,11 +686,12 @@ def test_vertex_filter_format_uri(): assert "uri" not in json.dumps(new_parameters) + def test_convert_schema_types_type_array_conversion(): """ Test _convert_schema_types function handles type arrays and case conversion. - - This test verifies the fix for the issue where type arrays like ["string", "number"] + + This test verifies the fix for the issue where type arrays like ["string", "number"] would raise an exception in Vertex AI schema validation. Relevant issue: https://github.com/BerriAI/litellm/issues/14091 @@ -673,12 +704,12 @@ def test_convert_schema_types_type_array_conversion(): "properties": { "studio": { "type": ["string", "number"], - "description": "The studio ID or name" + "description": "The studio ID or name", } }, "required": ["studio"], "additionalProperties": False, - "$schema": "http://json-schema.org/draft-07/schema#" + "$schema": "http://json-schema.org/draft-07/schema#", } # Expected output: Vertex AI compatible schema with anyOf and uppercase types @@ -686,16 +717,13 @@ def test_convert_schema_types_type_array_conversion(): "type": "object", "properties": { "studio": { - "anyOf": [ - {"type": "string"}, - {"type": "number"} - ], - "description": "The studio ID or name" + "anyOf": [{"type": "string"}, {"type": "number"}], + "description": "The studio ID or name", } }, "required": ["studio"], "additionalProperties": False, - "$schema": "http://json-schema.org/draft-07/schema#" + "$schema": "http://json-schema.org/draft-07/schema#", } # Apply the transformation @@ -718,15 +746,17 @@ def test_convert_schema_types_type_array_conversion(): assert anyof_types[1]["type"] == "number" # 4. Other properties preserved - assert input_schema["properties"]["studio"]["description"] == "The studio ID or name" + assert ( + input_schema["properties"]["studio"]["description"] == "The studio ID or name" + ) assert input_schema["required"] == ["studio"] def test_fix_enum_empty_strings(): """ Test _fix_enum_empty_strings function replaces empty strings with None in enum arrays. - - This test verifies the fix for the issue where Gemini rejects tool definitions + + This test verifies the fix for the issue where Gemini rejects tool definitions with empty strings in enum values, causing API failures. Relevant issue: Gemini does not accept empty strings in enum values @@ -740,23 +770,23 @@ def test_fix_enum_empty_strings(): "user_agent_type": { "enum": ["", "desktop", "mobile", "tablet"], "type": "string", - "description": "Device type for user agent" + "description": "Device type for user agent", } }, - "required": ["user_agent_type"] + "required": ["user_agent_type"], } # Expected output: Empty strings replaced with None expected_output = { - "type": "object", + "type": "object", "properties": { "user_agent_type": { "enum": [None, "desktop", "mobile", "tablet"], "type": "string", - "description": "Device type for user agent" + "description": "Device type for user agent", } }, - "required": ["user_agent_type"] + "required": ["user_agent_type"], } # Apply the transformation @@ -859,7 +889,7 @@ def test_construct_target_url_with_version_prefix(): def test_fix_enum_types(): """ Test _fix_enum_types function removes enum fields when type is not string. - + This test verifies the fix for the issue where Gemini rejects cached content with function parameter enums on non-string types, causing API failures. @@ -874,38 +904,41 @@ def test_fix_enum_types(): "truncateMode": { "enum": ["auto", "none", "start", "end"], "type": "string", # This should keep the enum - "description": "How to truncate content" + "description": "How to truncate content", }, "maxLength": { "enum": [100, 200, 500], # This should be removed "type": "integer", - "description": "Maximum length" + "description": "Maximum length", }, "enabled": { "enum": [True, False], # This should be removed "type": "boolean", - "description": "Whether feature is enabled" + "description": "Whether feature is enabled", }, "nested": { "type": "object", "properties": { "innerEnum": { "enum": ["a", "b", "c"], # This should be kept - "type": "string" + "type": "string", }, "innerNonStringEnum": { "enum": [1, 2, 3], # This should be removed - "type": "integer" - } - } + "type": "integer", + }, + }, }, "anyOfField": { "anyOf": [ - {"type": "string", "enum": ["option1", "option2"]}, # This should be kept - {"type": "integer", "enum": [1, 2, 3]} # This should be removed + { + "type": "string", + "enum": ["option1", "option2"], + }, # This should be kept + {"type": "integer", "enum": [1, 2, 3]}, # This should be removed ] - } - } + }, + }, } # Expected output: Non-string enums removed, string enums kept @@ -919,31 +952,32 @@ def test_fix_enum_types(): }, "maxLength": { # enum removed "type": "integer", - "description": "Maximum length" + "description": "Maximum length", }, "enabled": { # enum removed "type": "boolean", - "description": "Whether feature is enabled" + "description": "Whether feature is enabled", }, "nested": { "type": "object", "properties": { "innerEnum": { "enum": ["a", "b", "c"], # Kept - string type - "type": "string" + "type": "string", }, - "innerNonStringEnum": { # enum removed - "type": "integer" - } - } + "innerNonStringEnum": {"type": "integer"}, # enum removed + }, }, "anyOfField": { "anyOf": [ - {"type": "string", "enum": ["option1", "option2"]}, # Kept - has string type - {"type": "integer"} # enum removed + { + "type": "string", + "enum": ["option1", "option2"], + }, # Kept - has string type + {"type": "integer"}, # enum removed ] - } - } + }, + }, } # Apply the transformation @@ -955,15 +989,27 @@ def test_fix_enum_types(): # Verify specific transformations: # 1. String enums are preserved assert "enum" in input_schema["properties"]["truncateMode"] - assert input_schema["properties"]["truncateMode"]["enum"] == ["auto", "none", "start", "end"] - + assert input_schema["properties"]["truncateMode"]["enum"] == [ + "auto", + "none", + "start", + "end", + ] + assert "enum" in input_schema["properties"]["nested"]["properties"]["innerEnum"] - assert input_schema["properties"]["nested"]["properties"]["innerEnum"]["enum"] == ["a", "b", "c"] + assert input_schema["properties"]["nested"]["properties"]["innerEnum"]["enum"] == [ + "a", + "b", + "c", + ] # 2. Non-string enums are removed assert "enum" not in input_schema["properties"]["maxLength"] assert "enum" not in input_schema["properties"]["enabled"] - assert "enum" not in input_schema["properties"]["nested"]["properties"]["innerNonStringEnum"] + assert ( + "enum" + not in input_schema["properties"]["nested"]["properties"]["innerNonStringEnum"] + ) # 3. anyOf with string type keeps enum, non-string removes it assert "enum" in input_schema["properties"]["anyOfField"]["anyOf"][0] @@ -1003,8 +1049,6 @@ def test_get_token_url(): print("url=", url) - - should_use_v1beta1_features = vertex_llm.is_using_v1beta1_features( optional_params={"temperature": 0.1} ) @@ -1210,9 +1254,7 @@ def test_vertex_ai_minimax_uses_openai_handler(): VertexAIPartnerModels, ) - assert VertexAIPartnerModels.should_use_openai_handler( - "minimaxai/minimax-m2-maas" - ) + assert VertexAIPartnerModels.should_use_openai_handler("minimaxai/minimax-m2-maas") def test_vertex_ai_moonshot_uses_openai_handler(): @@ -1236,9 +1278,7 @@ def test_vertex_ai_zai_uses_openai_handler(): VertexAIPartnerModels, ) - assert VertexAIPartnerModels.should_use_openai_handler( - "zai-org/glm-4.7-maas" - ) + assert VertexAIPartnerModels.should_use_openai_handler("zai-org/glm-4.7-maas") def test_vertex_ai_zai_is_partner_model(): @@ -1255,14 +1295,14 @@ def test_vertex_ai_zai_is_partner_model(): def test_build_vertex_schema_empty_properties(): """ Test _build_vertex_schema handles empty properties objects correctly. - - This test verifies the fix for the issue where Gemini rejects schemas + + This test verifies the fix for the issue where Gemini rejects schemas with empty properties objects like {"properties": {}, "type": "object"}. - + Error from Gemini: "GenerateContentRequest.generation_config.response_schema - .properties[\"action\"].items.any_of[0].properties[\"go_back\"].properties: + .properties[\"action\"].items.any_of[0].properties[\"go_back\"].properties: should be non-empty for OBJECT type" - + The fix removes empty properties objects and their associated type/required fields. """ from litellm.llms.vertex_ai.common_utils import _build_vertex_schema @@ -1281,20 +1321,20 @@ def test_build_vertex_schema_empty_properties(): "type": "object", "additionalProperties": False, "description": "Go back", - "required": [] + "required": [], } }, "required": ["go_back"], "type": "object", - "additionalProperties": False + "additionalProperties": False, } ] }, - "type": "array" + "type": "array", } }, "type": "object", - "additionalProperties": False + "additionalProperties": False, } # Apply the transformation @@ -1302,24 +1342,36 @@ def test_build_vertex_schema_empty_properties(): # Verify the transformation removed empty properties # Navigate to the go_back schema - go_back_schema = result["properties"]["action"]["items"]["anyOf"][0]["properties"]["go_back"] - + go_back_schema = result["properties"]["action"]["items"]["anyOf"][0]["properties"][ + "go_back" + ] + # Verify empty properties was removed assert "properties" not in go_back_schema, "Empty properties should be removed" - + # Verify type is kept as object (Gemini requires type: object even without properties) - assert go_back_schema.get("type") == "object", "Type should be kept as object when properties is empty" - + assert ( + go_back_schema.get("type") == "object" + ), "Type should be kept as object when properties is empty" + # Verify required was also removed - assert "required" not in go_back_schema, "Required should be removed when properties is empty" - + assert ( + "required" not in go_back_schema + ), "Required should be removed when properties is empty" + # Verify description is preserved - assert go_back_schema.get("description") == "Go back", "Description should be preserved" - + assert ( + go_back_schema.get("description") == "Go back" + ), "Description should be preserved" + # Verify parent schema still has proper structure parent_schema = result["properties"]["action"]["items"]["anyOf"][0] - assert parent_schema["type"] == "object", "Parent schema should still have object type" - assert "go_back" in parent_schema["properties"], "go_back should still be in parent properties" + assert ( + parent_schema["type"] == "object" + ), "Parent schema should still have object type" + assert ( + "go_back" in parent_schema["properties"] + ), "go_back should still be in parent properties" def test_add_object_type_schema_with_no_properties_and_no_type(): @@ -1330,9 +1382,7 @@ def test_add_object_type_schema_with_no_properties_and_no_type(): from litellm.llms.vertex_ai.common_utils import add_object_type # Input: Schema with no properties and no type (the problematic case) - input_schema = { - "$schema": "https://json-schema.org/draft/2020-12/schema" - } + input_schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} # Apply the transformation add_object_type(input_schema) @@ -1351,10 +1401,7 @@ def test_add_object_type_does_not_override_existing_type(): from litellm.llms.vertex_ai.common_utils import add_object_type # Input: Schema with existing type - input_schema = { - "type": "string", - "description": "A string field" - } + input_schema = {"type": "string", "description": "A string field"} # Apply the transformation add_object_type(input_schema) @@ -1370,12 +1417,7 @@ def test_add_object_type_does_not_add_type_when_anyof_present(): from litellm.llms.vertex_ai.common_utils import add_object_type # Input: Schema with anyOf but no type - input_schema = { - "anyOf": [ - {"type": "string"}, - {"type": "null"} - ] - } + input_schema = {"anyOf": [{"type": "string"}, {"type": "null"}]} # Apply the transformation add_object_type(input_schema) From 6d81d7a04a3f3ff469f24802a8fa5161151fa79c Mon Sep 17 00:00:00 2001 From: balazss Date: Tue, 17 Mar 2026 21:40:13 -0700 Subject: [PATCH 04/13] fix(gemini): add oauth loopback timeout and normalize code assist model handling --- litellm/llms/gemini/authenticator.py | 33 +++++++-- .../llms/google_code_assist/transformation.py | 12 +--- litellm/main.py | 3 +- .../llms/gemini/test_gemini_authenticator.py | 68 +++++++++++++++++++ .../test_google_code_assist_transformation.py | 42 ++++++++++++ 5 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 tests/test_litellm/llms/gemini/test_gemini_authenticator.py create mode 100644 tests/test_litellm/llms/google_code_assist/test_google_code_assist_transformation.py diff --git a/litellm/llms/gemini/authenticator.py b/litellm/llms/gemini/authenticator.py index bd7d6d0b8dc..ef67116da69 100644 --- a/litellm/llms/gemini/authenticator.py +++ b/litellm/llms/gemini/authenticator.py @@ -53,6 +53,15 @@ class GeminiAuthenticator: ) return client_id, client_secret + @staticmethod + def _get_access_token_or_raise(creds: Dict[str, Any], source: str) -> str: + token = creds.get("access_token") + if not token or not isinstance(token, str): + raise Exception( + f"OAuth login failed: missing access_token in {source} response." + ) + return token + def get_token(self) -> str: """ Get the OAuth token, refreshing if necessary. @@ -88,7 +97,7 @@ class GeminiAuthenticator: verbose_logger.info("Starting Gemini OAuth login flow...") creds = self._login() self._write_oauth_creds(creds) - return creds.get("access_token") + return self._get_access_token_or_raise(creds, "authorization_code") def _refresh_token(self, refresh_token: str) -> str: """Refresh the access token using the refresh token.""" @@ -118,7 +127,7 @@ class GeminiAuthenticator: self._write_oauth_creds(creds) - return creds.get("access_token") + return self._get_access_token_or_raise(creds, "refresh_token") def _ensure_token_dir(self) -> None: """Ensure the token directory exists.""" @@ -234,9 +243,23 @@ class GeminiAuthenticator: webbrowser.open(auth_url) # Wait for callback; browsers may hit non-callback paths first (e.g. /favicon.ico). - while auth_code is None and error is None: - server.handle_request() - server.server_close() + # Use a bounded loop so headless/CI environments fail fast instead of hanging forever. + loopback_timeout_s = float( + os.getenv("GEMINI_OAUTH_LOOPBACK_TIMEOUT_SECONDS", "120") + ) + loopback_timeout_s = max(loopback_timeout_s, 1.0) + deadline = time.monotonic() + loopback_timeout_s + server.timeout = 1.0 + try: + while auth_code is None and error is None: + server.handle_request() + if time.monotonic() >= deadline: + raise Exception( + "OAuth login timed out. Re-run `litellm-proxy gemini login` " + f"and complete the browser flow within {int(loopback_timeout_s)} seconds." + ) + finally: + server.server_close() if error: raise Exception(f"OAuth login failed: {error}") diff --git a/litellm/llms/google_code_assist/transformation.py b/litellm/llms/google_code_assist/transformation.py index b58c7ed780d..273b53d7ba4 100644 --- a/litellm/llms/google_code_assist/transformation.py +++ b/litellm/llms/google_code_assist/transformation.py @@ -97,6 +97,7 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): # Create a copy to avoid mutating the original list messages_copy = copy.deepcopy(messages) + model_name = model.split("/")[-1] # Separate system instruction system_instruction, filtered_messages = _transform_system_message( @@ -105,14 +106,14 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): # Convert the rest of messages contents = _gemini_convert_messages_with_history( - messages=filtered_messages, model=model + messages=filtered_messages, model=model_name ) # 2. Build vertex-style nested request generation_config = {} # Handle parameter mapping base_params = self.map_openai_params( - {}, optional_params.copy(), model, messages + {}, optional_params.copy(), model_name, messages ) for key in ["temperature", "topP", "topK", "maxOutputTokens", "stopSequences"]: @@ -126,12 +127,6 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): generation_config["thinkingConfig"] = { "includeThoughts": base_params.pop("include_thoughts") } - elif "thinkingConfig" in optional_params: - generation_config["thinkingConfig"] = optional_params["thinkingConfig"] - elif "include_thoughts" in optional_params: - generation_config["thinkingConfig"] = { - "includeThoughts": optional_params["include_thoughts"] - } vertex_request = { "contents": contents, @@ -149,7 +144,6 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): # 3. Wrap in Code Assist envelope (matches verified gemini-cli structure) user_prompt_id = f"litellm-{uuid.uuid4()}" - model_name = model.split("/")[-1] ca_request = { "model": model_name, diff --git a/litellm/main.py b/litellm/main.py index b59ea1bc801..40807265949 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -3703,7 +3703,7 @@ def completion( # type: ignore # noqa: PLR0915 logger_fn=logger_fn, ) else: - model_response = google_code_assist_chat.completion( + response = google_code_assist_chat.completion( model=model, messages=messages, model_response=model_response, @@ -3713,7 +3713,6 @@ def completion( # type: ignore # noqa: PLR0915 logging_obj=logging, logger_fn=logger_fn, ) - response = model_response elif custom_llm_provider == "predibase": tenant_id = ( diff --git a/tests/test_litellm/llms/gemini/test_gemini_authenticator.py b/tests/test_litellm/llms/gemini/test_gemini_authenticator.py new file mode 100644 index 00000000000..889ff537c60 --- /dev/null +++ b/tests/test_litellm/llms/gemini/test_gemini_authenticator.py @@ -0,0 +1,68 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.llms.gemini.authenticator import GeminiAuthenticator + + +def test_get_token_raises_if_login_response_missing_access_token(tmp_path): + auth = GeminiAuthenticator() + auth.oauth_creds_file = str(tmp_path / "oauth_creds.json") + + with ( + patch.object(auth, "_login", return_value={"refresh_token": "abc"}), + patch.object(auth, "_write_oauth_creds"), + ): + with pytest.raises(Exception, match="missing access_token"): + auth.get_token() + + +def test_refresh_token_raises_if_response_missing_access_token(tmp_path): + auth = GeminiAuthenticator() + auth.oauth_creds_file = str(tmp_path / "oauth_creds.json") + + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {"expires_in": 3600} + + with ( + patch.object( + GeminiAuthenticator, + "_get_oauth_client_credentials", + return_value=("client-id", "client-secret"), + ), + patch("litellm.llms.gemini.authenticator.httpx.post", return_value=mock_resp), + patch.object(auth, "_write_oauth_creds"), + ): + with pytest.raises(Exception, match="missing access_token"): + auth._refresh_token("refresh-token") + + +def test_login_times_out_without_oauth_callback(monkeypatch): + auth = GeminiAuthenticator() + monkeypatch.setenv("GEMINI_OAUTH_LOOPBACK_TIMEOUT_SECONDS", "1") + + class _NoRequestServer: + def __init__(self, *args, **kwargs): + self.server_port = 12345 + self.timeout = None + + def handle_request(self): + return None + + def server_close(self): + return None + + with ( + patch.object( + GeminiAuthenticator, + "_get_oauth_client_credentials", + return_value=("client-id", "client-secret"), + ), + patch( + "litellm.llms.gemini.authenticator.http.server.HTTPServer", _NoRequestServer + ), + patch("litellm.llms.gemini.authenticator.webbrowser.open", return_value=True), + ): + with pytest.raises(Exception, match="OAuth login timed out"): + auth._login() diff --git a/tests/test_litellm/llms/google_code_assist/test_google_code_assist_transformation.py b/tests/test_litellm/llms/google_code_assist/test_google_code_assist_transformation.py new file mode 100644 index 00000000000..ccaebc5b2d6 --- /dev/null +++ b/tests/test_litellm/llms/google_code_assist/test_google_code_assist_transformation.py @@ -0,0 +1,42 @@ +from unittest.mock import patch + +from litellm.llms.google_code_assist.transformation import GoogleCodeAssistConfig + + +def test_transform_request_uses_base_model_name_for_internal_gemini_helpers(): + class _CaptureConfig(GoogleCodeAssistConfig): + def __init__(self): + super().__init__() + self.model_seen_by_map = None + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + messages: list, + ) -> dict: + self.model_seen_by_map = model + return {"thinkingConfig": {"includeThoughts": True}} + + config = _CaptureConfig() + with ( + patch( + "litellm.llms.vertex_ai.gemini.transformation._transform_system_message", + return_value=(None, [{"role": "user", "content": "hi"}]), + ), + patch( + "litellm.llms.vertex_ai.gemini.transformation._gemini_convert_messages_with_history", + return_value=[{"role": "user", "parts": [{"text": "hi"}]}], + ) as mock_convert, + ): + result = config.transform_request( + model="google_code_assist/gemini-2.5-pro", + messages=[{"role": "user", "content": "hi"}], + optional_params={"thinkingConfig": {"includeThoughts": False}}, + litellm_params={}, + ) + + assert config.model_seen_by_map == "gemini-2.5-pro" + assert mock_convert.call_args.kwargs["model"] == "gemini-2.5-pro" + assert result["model"] == "gemini-2.5-pro" From 9c3d36743afe63068e3fc7c128bcad5616ce4044 Mon Sep 17 00:00:00 2001 From: balazss Date: Tue, 17 Mar 2026 22:27:02 -0700 Subject: [PATCH 05/13] Update litellm/proxy/client/cli/commands/gemini.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/proxy/client/cli/commands/gemini.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/client/cli/commands/gemini.py b/litellm/proxy/client/cli/commands/gemini.py index f88e593fe53..6c54e735752 100644 --- a/litellm/proxy/client/cli/commands/gemini.py +++ b/litellm/proxy/client/cli/commands/gemini.py @@ -10,7 +10,7 @@ def gemini(): @gemini.command(name="login") def login(): - """Login to Gemini using OAuth Device Flow (Loopback)""" + """Authenticate with Gemini using the OAuth Authorization Code flow (loopback redirect)""" try: auth = GeminiAuthenticator() auth.get_token() From 6714590c9c8e41b67e7e923497d24ea15c13b997 Mon Sep 17 00:00:00 2001 From: balazss Date: Tue, 17 Mar 2026 22:37:55 -0700 Subject: [PATCH 06/13] fix(code-assist): offload async oauth lookup and validate token --- litellm/llms/gemini/fallback_handler.py | 6 +- litellm/llms/google_code_assist/chat.py | 13 +++- litellm/main.py | 7 +- .../test_google_code_assist.py | 76 +++++++++++++++++++ 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/litellm/llms/gemini/fallback_handler.py b/litellm/llms/gemini/fallback_handler.py index a59497657e6..08cccf222c7 100644 --- a/litellm/llms/gemini/fallback_handler.py +++ b/litellm/llms/gemini/fallback_handler.py @@ -4,6 +4,8 @@ from litellm._logging import verbose_logger from litellm.llms.gemini.common_utils import should_fallback_to_google_code_assist from litellm.llms.google_code_assist.chat import GoogleCodeAssistChat +_google_code_assist_chat = GoogleCodeAssistChat() + async def run_gemini_acompletion_with_code_assist_fallback( primary_call: Awaitable[Any], @@ -27,7 +29,7 @@ async def run_gemini_acompletion_with_code_assist_fallback( "Gemini request failed with ACCESS_TOKEN_SCOPE_INSUFFICIENT. " "Falling back to google_code_assist." ) - return await GoogleCodeAssistChat().acompletion(**fallback_kwargs) + return await _google_code_assist_chat.acompletion(**fallback_kwargs) def run_gemini_completion_with_code_assist_fallback( @@ -52,4 +54,4 @@ def run_gemini_completion_with_code_assist_fallback( "Gemini request failed with ACCESS_TOKEN_SCOPE_INSUFFICIENT. " "Falling back to google_code_assist." ) - return GoogleCodeAssistChat().completion(**fallback_kwargs) + return _google_code_assist_chat.completion(**fallback_kwargs) diff --git a/litellm/llms/google_code_assist/chat.py b/litellm/llms/google_code_assist/chat.py index 7bb74cb4a54..beea6ca997e 100644 --- a/litellm/llms/google_code_assist/chat.py +++ b/litellm/llms/google_code_assist/chat.py @@ -1,3 +1,4 @@ +import asyncio import httpx from typing import Any, Optional @@ -43,6 +44,11 @@ class GoogleCodeAssistChat: token = gemini_auth_data.get("token") initial_project_id = gemini_auth_data.get("project_id") + if not token: + raise GoogleCodeAssistError( + status_code=401, + message="Missing Gemini OAuth token value. Re-run 'litellm-proxy gemini login'.", + ) client = _get_httpx_client() @@ -102,7 +108,7 @@ class GoogleCodeAssistChat: try: from litellm.llms.gemini.common_utils import get_gemini_oauth_token - gemini_auth_data = get_gemini_oauth_token() + gemini_auth_data = await asyncio.to_thread(get_gemini_oauth_token) if not gemini_auth_data: raise GoogleCodeAssistError( status_code=401, @@ -111,6 +117,11 @@ class GoogleCodeAssistChat: token = gemini_auth_data.get("token") initial_project_id = gemini_auth_data.get("project_id") + if not token: + raise GoogleCodeAssistError( + status_code=401, + message="Missing Gemini OAuth token value. Re-run 'litellm-proxy gemini login'.", + ) async_handler = AsyncHTTPHandler() try: diff --git a/litellm/main.py b/litellm/main.py index 40807265949..26eaced03f6 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -161,6 +161,8 @@ from litellm.utils import ( validate_openai_optional_params, ) +_google_code_assist_chat = GoogleCodeAssistChat() + from ._logging import verbose_logger from .caching.caching import disable_cache, enable_cache, update_cache from .litellm_core_utils.core_helpers import safe_deep_copy @@ -3690,9 +3692,8 @@ def completion( # type: ignore # noqa: PLR0915 response = model_response elif custom_llm_provider == "google_code_assist": - google_code_assist_chat = GoogleCodeAssistChat() if acompletion is True: - response = google_code_assist_chat.acompletion( + response = _google_code_assist_chat.acompletion( model=model, messages=messages, model_response=model_response, @@ -3703,7 +3704,7 @@ def completion( # type: ignore # noqa: PLR0915 logger_fn=logger_fn, ) else: - response = google_code_assist_chat.completion( + response = _google_code_assist_chat.completion( model=model, messages=messages, model_response=model_response, diff --git a/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py b/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py index 5340443775e..c760e8d6ead 100644 --- a/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py +++ b/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py @@ -70,6 +70,22 @@ class TestGoogleCodeAssist: assert response.choices[0].message.content == "Hello world" assert response.usage.total_tokens == 7 + @patch("litellm.llms.gemini.common_utils.get_gemini_oauth_token") + def test_completion_raises_when_token_missing(self, mock_get_token): + mock_get_token.return_value = {"token": None} + + handler = GoogleCodeAssistChat() + with pytest.raises(Exception, match="Missing Gemini OAuth token value"): + handler.completion( + model="google_code_assist/gemini-1.5-flash", + messages=[{"role": "user", "content": "hi"}], + model_response=ModelResponse(), + print_verbose=False, + logging_obj=MagicMock(), + optional_params={}, + litellm_params={}, + ) + @pytest.mark.asyncio @patch("litellm.llms.google_code_assist.chat.AsyncHTTPHandler.post") @patch( @@ -132,3 +148,63 @@ class TestGoogleCodeAssist: assert response.choices[0].message.content == "Async success" mock_async_close.assert_awaited_once() + + @pytest.mark.asyncio + @patch( + "litellm.llms.google_code_assist.chat.asyncio.to_thread", new_callable=AsyncMock + ) + async def test_acompletion_uses_thread_for_oauth_lookup(self, mock_to_thread): + mock_to_thread.return_value = {"token": "test-token"} + + with ( + patch( + "litellm.llms.google_code_assist.chat.GoogleCodeAssistChat._ahandle_handshake", + new_callable=AsyncMock, + return_value="final-project", + ), + patch( + "litellm.llms.google_code_assist.chat.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_async_post, + patch( + "litellm.llms.google_code_assist.chat.AsyncHTTPHandler.close", + new_callable=AsyncMock, + ), + ): + completion_data = { + "response": { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "Async success"}], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 2, + "totalTokenCount": 7, + }, + } + } + mock_completion_resp = httpx.Response( + status_code=200, + content=json.dumps(completion_data).encode(), + request=httpx.Request("POST", "https://completion"), + ) + mock_async_post.return_value = mock_completion_resp + + handler = GoogleCodeAssistChat() + await handler.acompletion( + model="google_code_assist/gemini-1.5-flash", + messages=[{"role": "user", "content": "hi"}], + model_response=ModelResponse(), + print_verbose=False, + logging_obj=MagicMock(), + optional_params={}, + litellm_params={}, + ) + + mock_to_thread.assert_awaited_once() From 2b8ca172ebbbdd151b9b2d3fe88657c8e7896384 Mon Sep 17 00:00:00 2001 From: balazss Date: Tue, 17 Mar 2026 23:26:00 -0700 Subject: [PATCH 07/13] fix(gemini): harden vertex oauth path and dedupe code-assist urls --- litellm/llms/google_code_assist/chat.py | 12 +++-- .../llms/google_code_assist/transformation.py | 15 ++++++ .../vertex_and_google_ai_studio_gemini.py | 14 +++++ litellm/llms/vertex_ai/vertex_llm_base.py | 13 +++-- .../vertex_ai/test_vertex_ai_common_utils.py | 52 +++++++++++++++++++ 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/litellm/llms/google_code_assist/chat.py b/litellm/llms/google_code_assist/chat.py index beea6ca997e..07be7bb7ad9 100644 --- a/litellm/llms/google_code_assist/chat.py +++ b/litellm/llms/google_code_assist/chat.py @@ -7,6 +7,10 @@ from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import _get_httpx_client, AsyncHTTPHandler from .transformation import GoogleCodeAssistConfig, GoogleCodeAssistError +_CODE_ASSIST_BASE_URL = "https://cloudcode-pa.googleapis.com" +_CODE_ASSIST_GENERATE_URL = f"{_CODE_ASSIST_BASE_URL}/v1internal:generateContent" +_CODE_ASSIST_LOAD_URL = f"{_CODE_ASSIST_BASE_URL}/v1internal:loadCodeAssist" + class GoogleCodeAssistChat: """ @@ -65,7 +69,7 @@ class GoogleCodeAssistChat: ) # 4. Call Completion API - url = "https://cloudcode-pa.googleapis.com/v1internal:generateContent" + url = _CODE_ASSIST_GENERATE_URL headers = self._get_headers(token) response = client.post( @@ -133,7 +137,7 @@ class GoogleCodeAssistChat: data = self.config.transform_request( model, messages, optional_params, litellm_params ) - url = "https://cloudcode-pa.googleapis.com/v1internal:generateContent" + url = _CODE_ASSIST_GENERATE_URL headers = self._get_headers(token) response = await async_handler.post(url=url, headers=headers, json=data) @@ -165,7 +169,7 @@ class GoogleCodeAssistChat: self, client, token: str, initial_project_id: Optional[str] ) -> Optional[str]: """Performs the loadCodeAssist handshake to establish session context.""" - load_url = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + load_url = _CODE_ASSIST_LOAD_URL load_headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", @@ -197,7 +201,7 @@ class GoogleCodeAssistChat: initial_project_id: Optional[str], ) -> Optional[str]: """Async version of loadCodeAssist handshake for non-blocking async calls.""" - load_url = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + load_url = _CODE_ASSIST_LOAD_URL load_headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", diff --git a/litellm/llms/google_code_assist/transformation.py b/litellm/llms/google_code_assist/transformation.py index 273b53d7ba4..88e570cb591 100644 --- a/litellm/llms/google_code_assist/transformation.py +++ b/litellm/llms/google_code_assist/transformation.py @@ -4,6 +4,7 @@ import copy import httpx from typing import Any, List, Optional +from litellm._logging import verbose_logger from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.types.utils import ModelResponse from ..vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig @@ -127,6 +128,20 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): generation_config["thinkingConfig"] = { "includeThoughts": base_params.pop("include_thoughts") } + elif "thinkingConfig" in optional_params: + generation_config["thinkingConfig"] = optional_params["thinkingConfig"] + elif "include_thoughts" in optional_params: + generation_config["thinkingConfig"] = { + "includeThoughts": optional_params["include_thoughts"] + } + + if ( + "thinkingConfig" in optional_params + and "thinkingConfig" not in generation_config + ): + verbose_logger.warning( + "google_code_assist: `thinkingConfig` was provided but not mapped into generationConfig." + ) vertex_request = { "contents": contents, diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 3f1bccaccfc..ca127a4391e 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -2,6 +2,7 @@ ## httpx client for vertex ai calls ## Initial implementation - covers gemini + image gen calls import json +import asyncio import time from copy import deepcopy from functools import partial @@ -2504,6 +2505,11 @@ class VertexLLM(VertexBase): project_id=vertex_project, custom_llm_provider=custom_llm_provider, ) + gemini_auth_data = None + if custom_llm_provider == "gemini" and gemini_api_key is None: + from litellm.llms.gemini.common_utils import get_gemini_oauth_token + + gemini_auth_data = await asyncio.to_thread(get_gemini_oauth_token) # Extract use_psc_endpoint_format from optional_params use_psc_endpoint_format = optional_params.get("use_psc_endpoint_format", False) @@ -2511,6 +2517,7 @@ class VertexLLM(VertexBase): auth_header, api_base = self._get_token_and_url( model=model, gemini_api_key=gemini_api_key, + gemini_auth_data=gemini_auth_data, auth_header=_auth_header, vertex_project=vertex_project, vertex_location=vertex_location, @@ -2606,6 +2613,11 @@ class VertexLLM(VertexBase): project_id=vertex_project, custom_llm_provider=custom_llm_provider, ) + gemini_auth_data = None + if custom_llm_provider == "gemini" and gemini_api_key is None: + from litellm.llms.gemini.common_utils import get_gemini_oauth_token + + gemini_auth_data = await asyncio.to_thread(get_gemini_oauth_token) # Extract use_psc_endpoint_format from optional_params use_psc_endpoint_format = optional_params.get("use_psc_endpoint_format", False) @@ -2613,6 +2625,7 @@ class VertexLLM(VertexBase): auth_header, api_base = self._get_token_and_url( model=model, gemini_api_key=gemini_api_key, + gemini_auth_data=gemini_auth_data, auth_header=_auth_header, vertex_project=vertex_project, vertex_location=vertex_location, @@ -2801,6 +2814,7 @@ class VertexLLM(VertexBase): auth_header, url = self._get_token_and_url( model=model, gemini_api_key=gemini_api_key, + gemini_auth_data=None, auth_header=_auth_header, vertex_project=vertex_project, vertex_location=vertex_location, diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index 2ffe40d6185..e6f9eb9908a 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -368,6 +368,7 @@ class VertexBase: vertex_location: Optional[str] = None, vertex_api_version: Optional[Literal["v1", "v1beta1"]] = None, use_psc_endpoint_format: bool = False, + gemini_auth_data: Optional[dict] = None, ) -> Tuple[Optional[str], str]: """ for cloudflare ai gateway - https://github.com/BerriAI/litellm/issues/4317 @@ -395,8 +396,7 @@ class VertexBase: ) url = "{}/models/{}:{}".format(api_base, model, endpoint) - gemini_auth_data = None - if gemini_api_key is None: + if gemini_auth_data is None and gemini_api_key is None: from litellm.llms.gemini.common_utils import ( get_gemini_oauth_token, ) @@ -460,6 +460,7 @@ class VertexBase: should_use_v1beta1_features: Optional[bool] = False, mode: all_gemini_url_modes = "chat", use_psc_endpoint_format: bool = False, + gemini_auth_data: Optional[dict] = None, ) -> Tuple[Optional[str], str]: """ Internal function. Returns the token and url for the call. @@ -471,8 +472,7 @@ class VertexBase: """ version: Optional[Literal["v1beta1", "v1"]] = None if custom_llm_provider == "gemini": - gemini_auth_data = None - if gemini_api_key is None: + if gemini_auth_data is None and gemini_api_key is None: from litellm.llms.gemini.common_utils import get_gemini_oauth_token gemini_auth_data = get_gemini_oauth_token() @@ -491,6 +491,10 @@ class VertexBase: auth_header = {"Authorization": f"Bearer {gemini_oauth_token}"} if gemini_auth_data and gemini_auth_data.get("project_id"): auth_header["x-goog-user-project"] = gemini_auth_data["project_id"] + elif gemini_api_key is None: + raise ValueError( + "Missing gemini_api_key. Please set `GEMINI_API_KEY` or `GEMINI_OAUTH_TOKEN`." + ) else: auth_header = ( None # this field is not used for gemini when using api key @@ -517,6 +521,7 @@ class VertexBase: auth_header=auth_header, custom_llm_provider=custom_llm_provider, gemini_api_key=gemini_api_key, + gemini_auth_data=gemini_auth_data, endpoint=endpoint, stream=stream, url=url, diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py index 79db733e4aa..d2d1948ebf1 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py @@ -1074,6 +1074,58 @@ def test_get_token_url(): pass +def test_get_token_and_url_gemini_raises_if_no_api_key_and_no_oauth_token(): + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexLLM, + ) + + vertex_llm = VertexLLM() + with patch( + "litellm.llms.gemini.common_utils.get_gemini_oauth_token", return_value=None + ): + with pytest.raises(ValueError, match="Missing gemini_api_key"): + vertex_llm._get_token_and_url( + auth_header=None, + vertex_project="", + vertex_location="", + vertex_credentials="", + gemini_api_key=None, + custom_llm_provider="gemini", + should_use_v1beta1_features=False, + api_base=None, + model="gemini-2.5-pro", + stream=False, + ) + + +def test_get_token_and_url_gemini_uses_prefetched_auth_data_without_lookup(): + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexLLM, + ) + + vertex_llm = VertexLLM() + with patch("litellm.llms.gemini.common_utils.get_gemini_oauth_token") as mock_get: + auth_header, _ = vertex_llm._get_token_and_url( + auth_header=None, + vertex_project="", + vertex_location="", + vertex_credentials="", + gemini_api_key=None, + gemini_auth_data={"token": "oauth-token", "project_id": "my-project"}, + custom_llm_provider="gemini", + should_use_v1beta1_features=False, + api_base=None, + model="gemini-2.5-pro", + stream=False, + ) + + assert auth_header == { + "Authorization": "Bearer oauth-token", + "x-goog-user-project": "my-project", + } + mock_get.assert_not_called() + + @pytest.mark.asyncio async def test_vertex_ai_token_counter_routes_partner_models(): """ From 0872a655fdba4b6767d399ce1b92797b26dd835a Mon Sep 17 00:00:00 2001 From: balazss Date: Wed, 18 Mar 2026 00:43:26 -0700 Subject: [PATCH 08/13] fix(vertex): align stream checks and share code-assist singleton --- litellm/llms/gemini/fallback_handler.py | 4 ++-- litellm/llms/google_code_assist/chat.py | 10 ++++++++++ litellm/llms/vertex_ai/common_utils.py | 3 +-- litellm/main.py | 4 ++-- .../google_code_assist/test_google_code_assist.py | 8 +++++++- .../llms/vertex_ai/test_vertex_ai_common_utils.py | 13 +++++++++++++ 6 files changed, 35 insertions(+), 7 deletions(-) diff --git a/litellm/llms/gemini/fallback_handler.py b/litellm/llms/gemini/fallback_handler.py index 08cccf222c7..548cf5d5918 100644 --- a/litellm/llms/gemini/fallback_handler.py +++ b/litellm/llms/gemini/fallback_handler.py @@ -2,9 +2,9 @@ from typing import Any, Awaitable, Callable, Dict from litellm._logging import verbose_logger from litellm.llms.gemini.common_utils import should_fallback_to_google_code_assist -from litellm.llms.google_code_assist.chat import GoogleCodeAssistChat +from litellm.llms.google_code_assist.chat import get_google_code_assist_chat -_google_code_assist_chat = GoogleCodeAssistChat() +_google_code_assist_chat = get_google_code_assist_chat() async def run_gemini_acompletion_with_code_assist_fallback( diff --git a/litellm/llms/google_code_assist/chat.py b/litellm/llms/google_code_assist/chat.py index 07be7bb7ad9..b71f714abdc 100644 --- a/litellm/llms/google_code_assist/chat.py +++ b/litellm/llms/google_code_assist/chat.py @@ -246,3 +246,13 @@ class GoogleCodeAssistChat: if isinstance(e, GoogleCodeAssistError): return e return GoogleCodeAssistError(status_code=500, message=str(e)) + + +_shared_google_code_assist_chat: Optional[GoogleCodeAssistChat] = None + + +def get_google_code_assist_chat() -> GoogleCodeAssistChat: + global _shared_google_code_assist_chat + if _shared_google_code_assist_chat is None: + _shared_google_code_assist_chat = GoogleCodeAssistChat() + return _shared_google_code_assist_chat diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 754227e8873..511c0891e8c 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -349,7 +349,6 @@ def _get_gemini_url( "v1alpha" if VertexGeminiConfig._is_gemini_3_or_newer(model) else "v1beta" ) - endpoint = "generateContent" if mode == "chat": endpoint = "generateContent" if stream is True: @@ -374,7 +373,7 @@ def _get_gemini_url( params = [] if gemini_api_key and not gemini_oauth_token: params.append(f"key={gemini_api_key}") - if mode == "chat" and stream: + if mode == "chat" and stream is True: params.append("alt=sse") if params: diff --git a/litellm/main.py b/litellm/main.py index 26eaced03f6..d1971c53c0c 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -101,7 +101,7 @@ from litellm.llms.cohere.common_utils import CohereModelInfo from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config from litellm.llms.openai_like.json_loader import JSONProviderRegistry -from litellm.llms.google_code_assist.chat import GoogleCodeAssistChat +from litellm.llms.google_code_assist.chat import get_google_code_assist_chat from litellm.llms.gemini.fallback_handler import ( run_gemini_acompletion_with_code_assist_fallback, run_gemini_completion_with_code_assist_fallback, @@ -161,7 +161,7 @@ from litellm.utils import ( validate_openai_optional_params, ) -_google_code_assist_chat = GoogleCodeAssistChat() +_google_code_assist_chat = get_google_code_assist_chat() from ._logging import verbose_logger from .caching.caching import disable_cache, enable_cache, update_cache diff --git a/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py b/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py index c760e8d6ead..aaafdd5a5ba 100644 --- a/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py +++ b/tests/test_litellm/llms/google_code_assist/test_google_code_assist.py @@ -2,11 +2,17 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch import httpx import json -from litellm.llms.google_code_assist.chat import GoogleCodeAssistChat +from litellm.llms.google_code_assist.chat import ( + GoogleCodeAssistChat, + get_google_code_assist_chat, +) from litellm.types.utils import ModelResponse class TestGoogleCodeAssist: + def test_get_google_code_assist_chat_returns_singleton(self): + assert get_google_code_assist_chat() is get_google_code_assist_chat() + @patch("litellm.llms.google_code_assist.chat._get_httpx_client") @patch("litellm.llms.gemini.common_utils.get_gemini_oauth_token") def test_completion_basic(self, mock_get_token, mock_get_client): diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py index d2d1948ebf1..472b24314e4 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py @@ -581,6 +581,19 @@ def test_get_gemini_url_stream_query_param_only_for_chat_mode(): assert "alt=sse" not in embedding_url +def test_get_gemini_url_requires_literal_true_for_streaming_endpoint_and_alt_sse(): + url, endpoint = _get_gemini_url( + mode="chat", + model="gemini-1.5-flash", + stream=1, # truthy non-bool should not be treated as streaming=True + gemini_api_key="test-key", + gemini_oauth_token=None, + ) + + assert endpoint == "generateContent" + assert "alt=sse" not in url + + @pytest.mark.parametrize( "model_cost_entry, vertex_region, expected_region", [ From 05ceff8391a3e0bb2ab398abdb19926c14f0bd2b Mon Sep 17 00:00:00 2001 From: balazss Date: Wed, 18 Mar 2026 01:34:18 -0700 Subject: [PATCH 09/13] test(gemini): fix fallback handler patch targets --- tests/test_litellm/llms/gemini/test_fallback_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_litellm/llms/gemini/test_fallback_handler.py b/tests/test_litellm/llms/gemini/test_fallback_handler.py index 544f241a99b..1648aa61be0 100644 --- a/tests/test_litellm/llms/gemini/test_fallback_handler.py +++ b/tests/test_litellm/llms/gemini/test_fallback_handler.py @@ -18,7 +18,7 @@ def test_run_gemini_completion_with_code_assist_fallback_disabled(): return_value=True, ), patch( - "litellm.llms.gemini.fallback_handler.GoogleCodeAssistChat.completion" + "litellm.llms.gemini.fallback_handler._google_code_assist_chat.completion" ) as mock_completion, ): with pytest.raises(Exception, match="ACCESS_TOKEN_SCOPE_INSUFFICIENT"): @@ -42,7 +42,7 @@ async def test_run_gemini_acompletion_with_code_assist_fallback_enabled(): return_value=True, ), patch( - "litellm.llms.gemini.fallback_handler.GoogleCodeAssistChat.acompletion", + "litellm.llms.gemini.fallback_handler._google_code_assist_chat.acompletion", new_callable=AsyncMock, ) as mock_acompletion, ): From 90a7177db8343158776a7d1ca5e31b8c7ee85eeb Mon Sep 17 00:00:00 2001 From: balazss Date: Wed, 18 Mar 2026 09:45:03 -0700 Subject: [PATCH 10/13] fix(gemini): finish review follow-ups for fallback params and async auth --- .../llms/google_code_assist/transformation.py | 38 +----- .../vertex_and_google_ai_studio_gemini.py | 1 - .../batch_embed_content_handler.py | 129 ++++++++++++++---- .../embedding_handler.py | 116 ++++++++++++++-- .../vertex_embeddings/embedding_handler.py | 7 + litellm/main.py | 4 +- 6 files changed, 214 insertions(+), 81 deletions(-) diff --git a/litellm/llms/google_code_assist/transformation.py b/litellm/llms/google_code_assist/transformation.py index 88e570cb591..f2908dc3a81 100644 --- a/litellm/llms/google_code_assist/transformation.py +++ b/litellm/llms/google_code_assist/transformation.py @@ -2,7 +2,7 @@ import json import uuid import copy import httpx -from typing import Any, List, Optional +from typing import Any from litellm._logging import verbose_logger from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -49,36 +49,6 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): - `stop_sequences` (List[str]): The set of character sequences that will stop output generation. """ - def __init__( - self, - temperature: Optional[float] = None, - max_output_tokens: Optional[int] = None, - top_p: Optional[float] = None, - top_k: Optional[int] = None, - stop_sequences: Optional[list] = None, - ) -> None: - super().__init__( - temperature=temperature, - max_output_tokens=max_output_tokens, - top_p=top_p, - top_k=top_k, - stop_sequences=stop_sequences, - ) - - def get_supported_openai_params(self, model: str) -> List[str]: - return super().get_supported_openai_params(model) - - def map_openai_params( - self, - non_default_params: dict, - optional_params: dict, - model: str, - messages: list, - ) -> dict: - return super().map_openai_params( - non_default_params, optional_params, model, messages - ) - def transform_request( self, model: str, @@ -128,12 +98,6 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): generation_config["thinkingConfig"] = { "includeThoughts": base_params.pop("include_thoughts") } - elif "thinkingConfig" in optional_params: - generation_config["thinkingConfig"] = optional_params["thinkingConfig"] - elif "include_thoughts" in optional_params: - generation_config["thinkingConfig"] = { - "includeThoughts": optional_params["include_thoughts"] - } if ( "thinkingConfig" in optional_params diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index ca127a4391e..fe8b60bcc29 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -2814,7 +2814,6 @@ class VertexLLM(VertexBase): auth_header, url = self._get_token_and_url( model=model, gemini_api_key=gemini_api_key, - gemini_auth_data=None, auth_header=_auth_header, vertex_project=vertex_project, vertex_location=vertex_location, diff --git a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py index 2371bc4865a..df77f7654b0 100644 --- a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py +++ b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py @@ -2,6 +2,7 @@ Google AI Studio /batchEmbedContents Embeddings Endpoint """ +import asyncio import json from typing import Any, Dict, Literal, Optional, Union @@ -130,6 +131,36 @@ class GoogleBatchEmbeddings(VertexLLM): client=None, extra_headers: Optional[dict] = None, ) -> EmbeddingResponse: + optional_params = optional_params or {} + + is_multimodal = _is_multimodal_input(input) + use_embed_content = is_multimodal or (custom_llm_provider == "vertex_ai") + mode: Literal["embedding", "batch_embedding"] + if use_embed_content: + mode = "embedding" + else: + mode = "batch_embedding" + + if aembedding is True: + return self._async_batch_embeddings_with_auth_resolution( # type: ignore + model=model, + input=input, + model_response=model_response, + custom_llm_provider=custom_llm_provider, + optional_params=optional_params, + logging_obj=logging_obj, + api_key=api_key, + api_base=api_base, + use_embed_content=use_embed_content, + mode=mode, + timeout=timeout, + client=client, + vertex_project=vertex_project, + vertex_location=vertex_location, + vertex_credentials=vertex_credentials, + extra_headers=extra_headers, + ) + _auth_header, vertex_project = self._ensure_access_token( credentials=vertex_credentials, project_id=vertex_project, @@ -149,16 +180,6 @@ class GoogleBatchEmbeddings(VertexLLM): else: sync_handler = client # type: ignore - optional_params = optional_params or {} - - is_multimodal = _is_multimodal_input(input) - use_embed_content = is_multimodal or (custom_llm_provider == "vertex_ai") - mode: Literal["embedding", "batch_embedding"] - if use_embed_content: - mode = "embedding" - else: - mode = "batch_embedding" - auth_header, url = self._get_token_and_url( model=model, auth_header=_auth_header, @@ -184,22 +205,6 @@ class GoogleBatchEmbeddings(VertexLLM): if extra_headers is not None: headers.update(extra_headers) - if aembedding is True: - return self.async_batch_embeddings( # type: ignore - model=model, - api_base=api_base, - url=url, - data=None, - model_response=model_response, - timeout=timeout, - headers=headers, - input=input, - use_embed_content=use_embed_content, - api_key=api_key, - optional_params=optional_params, - logging_obj=logging_obj, - ) - ### TRANSFORMATION (sync path) ### request_data: Any if use_embed_content: @@ -257,6 +262,78 @@ class GoogleBatchEmbeddings(VertexLLM): input=input, ) + async def _async_batch_embeddings_with_auth_resolution( + self, + model: str, + input: EmbeddingInput, + model_response: EmbeddingResponse, + custom_llm_provider: Literal["gemini", "vertex_ai"], + optional_params: dict, + logging_obj: Any, + api_key: Optional[str], + api_base: Optional[str], + use_embed_content: bool, + mode: Literal["embedding", "batch_embedding"], + timeout: Optional[Union[float, httpx.Timeout]], + client: Optional[AsyncHTTPHandler], + vertex_project: Optional[str], + vertex_location: Optional[str], + vertex_credentials: Optional[Any], + extra_headers: Optional[dict], + ) -> EmbeddingResponse: + _auth_header, vertex_project = await self._ensure_access_token_async( + credentials=vertex_credentials, + project_id=vertex_project, + custom_llm_provider=custom_llm_provider, + ) + gemini_auth_data = None + if custom_llm_provider == "gemini" and api_key is None: + from litellm.llms.gemini.common_utils import get_gemini_oauth_token + + gemini_auth_data = await asyncio.to_thread(get_gemini_oauth_token) + + auth_header, url = self._get_token_and_url( + model=model, + auth_header=_auth_header, + gemini_api_key=api_key, + gemini_auth_data=gemini_auth_data, + vertex_project=vertex_project, + vertex_location=vertex_location, + vertex_credentials=vertex_credentials, + stream=None, + custom_llm_provider=custom_llm_provider, + api_base=api_base, + should_use_v1beta1_features=False, + mode=mode, + ) + + headers = { + "Content-Type": "application/json; charset=utf-8", + } + if auth_header is not None: + if isinstance(auth_header, dict): + headers.update(auth_header) + else: + headers["Authorization"] = f"Bearer {auth_header}" + if extra_headers is not None: + headers.update(extra_headers) + + return await self.async_batch_embeddings( + model=model, + api_base=api_base, + url=url, + data=None, + model_response=model_response, + timeout=timeout, + headers=headers, + client=client, + input=input, + use_embed_content=use_embed_content, + api_key=api_key, + optional_params=optional_params, + logging_obj=logging_obj, + ) + async def async_batch_embeddings( self, model: str, diff --git a/litellm/llms/vertex_ai/multimodal_embeddings/embedding_handler.py b/litellm/llms/vertex_ai/multimodal_embeddings/embedding_handler.py index d0ffc7be0a6..53ff92fb9f2 100644 --- a/litellm/llms/vertex_ai/multimodal_embeddings/embedding_handler.py +++ b/litellm/llms/vertex_ai/multimodal_embeddings/embedding_handler.py @@ -1,3 +1,4 @@ +import asyncio import json from typing import Literal, Optional, Union @@ -50,6 +51,25 @@ class VertexMultimodalEmbedding(VertexLLM): timeout=300, client=None, ) -> EmbeddingResponse: + if aembedding is True: + return self._async_multimodal_embedding_with_auth_resolution( # type: ignore + model=model, + input=input, + model_response=model_response, + custom_llm_provider=custom_llm_provider, + optional_params=optional_params, + litellm_params=litellm_params, + logging_obj=logging_obj, + api_key=api_key, + api_base=api_base, + headers=headers, + timeout=timeout, + client=client, + vertex_project=vertex_project, + vertex_location=vertex_location, + vertex_credentials=vertex_credentials, + ) + _auth_header, vertex_project = self._ensure_access_token( credentials=vertex_credentials, project_id=vertex_project, @@ -108,21 +128,6 @@ class VertexMultimodalEmbedding(VertexLLM): }, ) - if aembedding is True: - return self.async_multimodal_embedding( # type: ignore - model=model, - api_base=url, - data=request_data, - timeout=timeout, - headers=headers, - client=client, - model_response=model_response, - optional_params=optional_params, - litellm_params=litellm_params, - logging_obj=logging_obj, - api_key=api_key, - ) - response = sync_handler.post( url=url, headers=headers, @@ -140,6 +145,87 @@ class VertexMultimodalEmbedding(VertexLLM): litellm_params=litellm_params, ) + async def _async_multimodal_embedding_with_auth_resolution( + self, + model: str, + input: Union[list, str], + model_response: EmbeddingResponse, + custom_llm_provider: Literal["gemini", "vertex_ai"], + optional_params: dict, + litellm_params: dict, + logging_obj: LiteLLMLoggingObj, + api_key: Optional[str], + api_base: Optional[str], + headers: dict, + timeout: Optional[Union[float, httpx.Timeout]], + client: Optional[AsyncHTTPHandler], + vertex_project: Optional[str], + vertex_location: Optional[str], + vertex_credentials: Optional[VERTEX_CREDENTIALS_TYPES], + ) -> EmbeddingResponse: + _auth_header, vertex_project = await self._ensure_access_token_async( + credentials=vertex_credentials, + project_id=vertex_project, + custom_llm_provider=custom_llm_provider, + ) + gemini_auth_data = None + if custom_llm_provider == "gemini" and api_key is None: + from litellm.llms.gemini.common_utils import get_gemini_oauth_token + + gemini_auth_data = await asyncio.to_thread(get_gemini_oauth_token) + + auth_header, url = self._get_token_and_url( + model=model, + auth_header=_auth_header, + gemini_api_key=api_key, + gemini_auth_data=gemini_auth_data, + vertex_project=vertex_project, + vertex_location=vertex_location, + vertex_credentials=vertex_credentials, + stream=None, + custom_llm_provider=custom_llm_provider, + api_base=api_base, + should_use_v1beta1_features=False, + mode="embedding", + ) + + request_data = vertex_multimodal_embedding_handler.transform_embedding_request( + model, input, optional_params, headers + ) + request_headers = vertex_multimodal_embedding_handler.validate_environment( + headers=headers, + model=model, + messages=[], + optional_params=optional_params, + api_key=auth_header, + api_base=api_base, + litellm_params=litellm_params, + ) + + logging_obj.pre_call( + input=input, + api_key="", + additional_args={ + "complete_input_dict": request_data, + "api_base": url, + "headers": request_headers, + }, + ) + + return await self.async_multimodal_embedding( + model=model, + api_base=url, + data=request_data, + timeout=timeout, + headers=request_headers, + client=client, + model_response=model_response, + optional_params=optional_params, + litellm_params=litellm_params, + logging_obj=logging_obj, + api_key=api_key, + ) + async def async_multimodal_embedding( self, model: str, diff --git a/litellm/llms/vertex_ai/vertex_embeddings/embedding_handler.py b/litellm/llms/vertex_ai/vertex_embeddings/embedding_handler.py index 5fffd983c24..e1a787f8147 100644 --- a/litellm/llms/vertex_ai/vertex_embeddings/embedding_handler.py +++ b/litellm/llms/vertex_ai/vertex_embeddings/embedding_handler.py @@ -1,3 +1,4 @@ +import asyncio from typing import Literal, Optional, Union import httpx @@ -166,12 +167,18 @@ class VertexEmbedding(VertexBase): project_id=vertex_project, custom_llm_provider=custom_llm_provider, ) + gemini_auth_data = None + if custom_llm_provider == "gemini" and gemini_api_key is None: + from litellm.llms.gemini.common_utils import get_gemini_oauth_token + + gemini_auth_data = await asyncio.to_thread(get_gemini_oauth_token) # Extract use_psc_endpoint_format from optional_params use_psc_endpoint_format = optional_params.get("use_psc_endpoint_format", False) auth_header, api_base = self._get_token_and_url( model=model, gemini_api_key=gemini_api_key, + gemini_auth_data=gemini_auth_data, auth_header=_auth_header, vertex_project=vertex_project, vertex_location=vertex_location, diff --git a/litellm/main.py b/litellm/main.py index d1971c53c0c..a20ffb03bb6 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -3473,7 +3473,7 @@ def completion( # type: ignore # noqa: PLR0915 "messages": messages, "model_response": model_response, "print_verbose": print_verbose, - "optional_params": optional_params, + "optional_params": optional_params or {}, "litellm_params": litellm_params, # type: ignore "logging_obj": logging, "logger_fn": logger_fn, @@ -3508,7 +3508,7 @@ def completion( # type: ignore # noqa: PLR0915 "messages": messages, "model_response": model_response, "print_verbose": print_verbose, - "optional_params": optional_params, + "optional_params": optional_params or {}, "litellm_params": litellm_params, # type: ignore "logging_obj": logging, "logger_fn": logger_fn, From 8eaa1582ebd73b9965ca5064c1af84b5356d4f04 Mon Sep 17 00:00:00 2001 From: balazss Date: Wed, 18 Mar 2026 18:32:49 -0700 Subject: [PATCH 11/13] fix(gemini): harden code-assist params and atomic oauth creds writes --- litellm/llms/gemini/authenticator.py | 26 ++++++++------ .../llms/google_code_assist/transformation.py | 15 ++++---- litellm/main.py | 5 +-- .../llms/gemini/test_fallback_handler.py | 36 ++++++++++++++----- 4 files changed, 54 insertions(+), 28 deletions(-) diff --git a/litellm/llms/gemini/authenticator.py b/litellm/llms/gemini/authenticator.py index ef67116da69..9e317d4fe20 100644 --- a/litellm/llms/gemini/authenticator.py +++ b/litellm/llms/gemini/authenticator.py @@ -1,5 +1,6 @@ import json import os +import tempfile import time import webbrowser import http.server @@ -143,17 +144,22 @@ class GeminiAuthenticator: """ Write oauth credentials with user-only permissions. """ - fd = os.open( - self.oauth_creds_file, - os.O_WRONLY | os.O_CREAT | os.O_TRUNC, - 0o600, - ) - with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump(creds, f) + fd, tmp_path = tempfile.mkstemp(dir=self.token_dir, prefix=".tmp_creds_") try: - os.chmod(self.oauth_creds_file, 0o600) - except OSError: - pass + os.chmod(tmp_path, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(creds, f) + os.replace(tmp_path, self.oauth_creds_file) + try: + os.chmod(self.oauth_creds_file, 0o600) + except OSError: + pass + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise def _login(self) -> Dict[str, Any]: """Perform loopback flow login.""" diff --git a/litellm/llms/google_code_assist/transformation.py b/litellm/llms/google_code_assist/transformation.py index f2908dc3a81..441af4e9988 100644 --- a/litellm/llms/google_code_assist/transformation.py +++ b/litellm/llms/google_code_assist/transformation.py @@ -99,13 +99,14 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): "includeThoughts": base_params.pop("include_thoughts") } - if ( - "thinkingConfig" in optional_params - and "thinkingConfig" not in generation_config - ): - verbose_logger.warning( - "google_code_assist: `thinkingConfig` was provided but not mapped into generationConfig." - ) + # Forward any remaining mapped params into generation_config so they are not silently dropped. + for key, value in base_params.items(): + if key not in generation_config: + verbose_logger.debug( + "google_code_assist: forwarding mapped param '%s' into generationConfig", + key, + ) + generation_config[key] = value vertex_request = { "contents": contents, diff --git a/litellm/main.py b/litellm/main.py index a20ffb03bb6..6b9c4f776d8 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -3692,13 +3692,14 @@ def completion( # type: ignore # noqa: PLR0915 response = model_response elif custom_llm_provider == "google_code_assist": + _ca_optional_params = optional_params or {} if acompletion is True: response = _google_code_assist_chat.acompletion( model=model, messages=messages, model_response=model_response, print_verbose=print_verbose, - optional_params=optional_params, + optional_params=_ca_optional_params, litellm_params=litellm_params, # type: ignore logging_obj=logging, logger_fn=logger_fn, @@ -3709,7 +3710,7 @@ def completion( # type: ignore # noqa: PLR0915 messages=messages, model_response=model_response, print_verbose=print_verbose, - optional_params=optional_params, + optional_params=_ca_optional_params, litellm_params=litellm_params, # type: ignore logging_obj=logging, logger_fn=logger_fn, diff --git a/tests/test_litellm/llms/gemini/test_fallback_handler.py b/tests/test_litellm/llms/gemini/test_fallback_handler.py index 1648aa61be0..d70e9984ba9 100644 --- a/tests/test_litellm/llms/gemini/test_fallback_handler.py +++ b/tests/test_litellm/llms/gemini/test_fallback_handler.py @@ -12,15 +12,9 @@ def test_run_gemini_completion_with_code_assist_fallback_disabled(): def _raise_scope_error(): raise Exception("ACCESS_TOKEN_SCOPE_INSUFFICIENT") - with ( - patch( - "litellm.llms.gemini.fallback_handler.should_fallback_to_google_code_assist", - return_value=True, - ), - patch( - "litellm.llms.gemini.fallback_handler._google_code_assist_chat.completion" - ) as mock_completion, - ): + with patch( + "litellm.llms.gemini.fallback_handler._google_code_assist_chat.completion" + ) as mock_completion: with pytest.raises(Exception, match="ACCESS_TOKEN_SCOPE_INSUFFICIENT"): run_gemini_completion_with_code_assist_fallback( primary_call=_raise_scope_error, @@ -31,6 +25,30 @@ def test_run_gemini_completion_with_code_assist_fallback_disabled(): mock_completion.assert_not_called() +def test_run_gemini_completion_with_code_assist_fallback_enabled_but_not_match(): + def _raise_other_error(): + raise Exception("SOME_OTHER_ERROR") + + with ( + patch( + "litellm.llms.gemini.fallback_handler.should_fallback_to_google_code_assist", + return_value=False, + ) as mock_should_fallback, + patch( + "litellm.llms.gemini.fallback_handler._google_code_assist_chat.completion" + ) as mock_completion, + ): + with pytest.raises(Exception, match="SOME_OTHER_ERROR"): + run_gemini_completion_with_code_assist_fallback( + primary_call=_raise_other_error, + fallback_kwargs={}, + auto_fallback_to_google_code_assist=True, + ) + + mock_should_fallback.assert_called_once() + mock_completion.assert_not_called() + + @pytest.mark.asyncio async def test_run_gemini_acompletion_with_code_assist_fallback_enabled(): async def _raise_scope_error(): From 092876d377948c11fcc12e2056fd4f7fd02c695d Mon Sep 17 00:00:00 2001 From: balazss Date: Mon, 4 May 2026 13:38:16 -0700 Subject: [PATCH 12/13] fix(gemini): address remaining greptile review comments - google_code_assist: log unrecognized optional_params keys for visibility - gemini/authenticator: document timeout overshoot in OAuth loopback loop - gemini/fallback_handler: take primary_call as Callable so async sync-setup errors are caught by the fallback try/except (symmetric with sync path) - gemini/google_genai: pre-fetch OAuth via asyncio.to_thread in async get_auth_token_and_url to avoid blocking the event loop - tests/google_code_assist: add missing __init__.py for parallel pytest discovery - tests/fallback_handler: pass deferred coroutine factory to async fallback wrapper Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/llms/gemini/authenticator.py | 1 + litellm/llms/gemini/fallback_handler.py | 4 ++-- litellm/llms/gemini/google_genai/transformation.py | 11 +++++++++++ litellm/llms/google_code_assist/transformation.py | 10 ++++++++++ litellm/main.py | 2 +- .../test_litellm/llms/gemini/test_fallback_handler.py | 2 +- .../test_litellm/llms/google_code_assist/__init__.py | 0 7 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 tests/test_litellm/llms/google_code_assist/__init__.py diff --git a/litellm/llms/gemini/authenticator.py b/litellm/llms/gemini/authenticator.py index 9e317d4fe20..89b44a09601 100644 --- a/litellm/llms/gemini/authenticator.py +++ b/litellm/llms/gemini/authenticator.py @@ -257,6 +257,7 @@ class GeminiAuthenticator: deadline = time.monotonic() + loopback_timeout_s server.timeout = 1.0 try: + # Note: deadline is checked after handle_request() returns, so the loop can overshoot by up to server.timeout (1s) — acceptable for a one-shot CLI login. while auth_code is None and error is None: server.handle_request() if time.monotonic() >= deadline: diff --git a/litellm/llms/gemini/fallback_handler.py b/litellm/llms/gemini/fallback_handler.py index 548cf5d5918..cd1cf75d5bc 100644 --- a/litellm/llms/gemini/fallback_handler.py +++ b/litellm/llms/gemini/fallback_handler.py @@ -8,7 +8,7 @@ _google_code_assist_chat = get_google_code_assist_chat() async def run_gemini_acompletion_with_code_assist_fallback( - primary_call: Awaitable[Any], + primary_call: Callable[[], Awaitable[Any]], fallback_kwargs: Dict[str, Any], auto_fallback_to_google_code_assist: bool = False, ) -> Any: @@ -17,7 +17,7 @@ async def run_gemini_acompletion_with_code_assist_fallback( OAuth scope is insufficient. """ try: - return await primary_call + return await primary_call() except Exception as e: if not auto_fallback_to_google_code_assist: raise e diff --git a/litellm/llms/gemini/google_genai/transformation.py b/litellm/llms/gemini/google_genai/transformation.py index 7c4c7dba626..7b5dafea8b6 100644 --- a/litellm/llms/gemini/google_genai/transformation.py +++ b/litellm/llms/gemini/google_genai/transformation.py @@ -2,6 +2,7 @@ Transformation for Calling Google models in their native format. """ +import asyncio from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast import httpx @@ -11,6 +12,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.llms.base_llm.google_genai.transformation import ( BaseGoogleGenAIGenerateContentConfig, ) +from litellm.llms.gemini.common_utils import get_gemini_oauth_token from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.types.router import GenericLiteLLMParams @@ -200,6 +202,7 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM): stream: bool, api_base: Optional[str], litellm_params: dict, + gemini_auth_data: Optional[dict] = None, ) -> Tuple[dict, str]: """ Build final headers and API URL from auth components. @@ -217,6 +220,7 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM): custom_llm_provider=self.custom_llm_provider, api_base=api_base, should_use_v1beta1_features=True, + gemini_auth_data=gemini_auth_data, ) headers = self.validate_environment( @@ -291,6 +295,12 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM): custom_llm_provider=self.custom_llm_provider, ) + gemini_auth_data: Optional[dict] = None + if self.custom_llm_provider == "gemini": + gemini_api_key = self._get_google_ai_studio_api_key(dict(litellm_params)) + if gemini_api_key is None: + gemini_auth_data = await asyncio.to_thread(get_gemini_oauth_token) + return self._build_final_headers_and_url( model=model, auth_header=_auth_header, @@ -300,6 +310,7 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM): stream=stream, api_base=api_base, litellm_params=litellm_params, + gemini_auth_data=gemini_auth_data, ) def transform_generate_content_request( diff --git a/litellm/llms/google_code_assist/transformation.py b/litellm/llms/google_code_assist/transformation.py index 441af4e9988..6de5b608ea0 100644 --- a/litellm/llms/google_code_assist/transformation.py +++ b/litellm/llms/google_code_assist/transformation.py @@ -108,6 +108,16 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): ) generation_config[key] = value + # Log unmapped/unrecognized caller keys so users have visibility (do not auto-forward — + # they may not be valid Gemini fields). + for key in optional_params: + if key not in base_params and key not in generation_config: + verbose_logger.debug( + "google_code_assist: dropping unrecognized optional_param '%s' " + "(not produced by VertexGeminiConfig.map_openai_params)", + key, + ) + vertex_request = { "contents": contents, "session_id": litellm_params.get("session_id", str(uuid.uuid4())), diff --git a/litellm/main.py b/litellm/main.py index 6b9c4f776d8..0734613f50c 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -3447,7 +3447,7 @@ def completion( # type: ignore # noqa: PLR0915 new_params = safe_deep_copy(optional_params or {}) if acompletion is True: response = run_gemini_acompletion_with_code_assist_fallback( - primary_call=vertex_chat_completion.completion( # type: ignore + primary_call=lambda: vertex_chat_completion.completion( # type: ignore model=model, messages=messages, model_response=model_response, diff --git a/tests/test_litellm/llms/gemini/test_fallback_handler.py b/tests/test_litellm/llms/gemini/test_fallback_handler.py index d70e9984ba9..74e2f144627 100644 --- a/tests/test_litellm/llms/gemini/test_fallback_handler.py +++ b/tests/test_litellm/llms/gemini/test_fallback_handler.py @@ -66,7 +66,7 @@ async def test_run_gemini_acompletion_with_code_assist_fallback_enabled(): ): mock_acompletion.return_value = "fallback-ok" result = await run_gemini_acompletion_with_code_assist_fallback( - primary_call=_raise_scope_error(), + primary_call=_raise_scope_error, fallback_kwargs={}, auto_fallback_to_google_code_assist=True, ) diff --git a/tests/test_litellm/llms/google_code_assist/__init__.py b/tests/test_litellm/llms/google_code_assist/__init__.py new file mode 100644 index 00000000000..e69de29bb2d From b0d5e0c2cb86beb1ef147b4fe652243273b0561e Mon Sep 17 00:00:00 2001 From: balazss Date: Mon, 4 May 2026 14:11:30 -0700 Subject: [PATCH 13/13] =?UTF-8?q?fix(gemini):=20address=20CI=20failures=20?= =?UTF-8?q?=E2=80=94=20MyPy,=20provider=20docs,=20test=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add google_code_assist to provider_endpoints_support.json (code-quality CI) - Fix MyPy signature mismatches in GoogleCodeAssistConfig.transform_request and transform_response (add missing headers/api_key/json_mode params) - Fix map_openai_params 4th arg: pass drop_params=False instead of messages list - Add type: ignore[assignment/index] on vertex_llm_base auth_header dict assignments - Type load_payload as Dict[str, Any] in chat.py to allow mixed-value dict - Update test match= strings from "Missing Gemini API key" to "Missing gemini_api_key" to match updated error message in _check_custom_proxy and _get_token_and_url Co-Authored-By: Claude Sonnet 4.6 --- litellm/llms/google_code_assist/chat.py | 6 ++--- .../llms/google_code_assist/transformation.py | 7 ++++-- litellm/llms/vertex_ai/vertex_llm_base.py | 8 +++---- provider_endpoints_support.json | 23 +++++++++++++++++++ .../test_google_gemini_proxy_request.py | 2 +- .../llms/vertex_ai/test_vertex_llm_base.py | 2 +- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/litellm/llms/google_code_assist/chat.py b/litellm/llms/google_code_assist/chat.py index b71f714abdc..26c30e2b3d1 100644 --- a/litellm/llms/google_code_assist/chat.py +++ b/litellm/llms/google_code_assist/chat.py @@ -1,6 +1,6 @@ import asyncio import httpx -from typing import Any, Optional +from typing import Any, Dict, Optional import litellm from litellm._logging import verbose_logger @@ -175,7 +175,7 @@ class GoogleCodeAssistChat: "Content-Type": "application/json", "User-Agent": "GeminiCLI/litellm", } - load_payload = { + load_payload: Dict[str, Any] = { "metadata": { "ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", @@ -207,7 +207,7 @@ class GoogleCodeAssistChat: "Content-Type": "application/json", "User-Agent": "GeminiCLI/litellm", } - load_payload = { + load_payload: Dict[str, Any] = { "metadata": { "ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", diff --git a/litellm/llms/google_code_assist/transformation.py b/litellm/llms/google_code_assist/transformation.py index 6de5b608ea0..49c87c10b04 100644 --- a/litellm/llms/google_code_assist/transformation.py +++ b/litellm/llms/google_code_assist/transformation.py @@ -55,6 +55,7 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): messages: list, optional_params: dict, litellm_params: dict, + headers: dict = {}, ) -> dict: """ Transforms standard LiteLLM request to Code Assist API format. @@ -84,7 +85,7 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): generation_config = {} # Handle parameter mapping base_params = self.map_openai_params( - {}, optional_params.copy(), model_name, messages + {}, optional_params.copy(), model_name, False ) for key in ["temperature", "topP", "topK", "maxOutputTokens", "stopSequences"]: @@ -158,6 +159,8 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): optional_params: dict, litellm_params: dict, encoding: Any, + api_key: Any = None, + json_mode: Any = None, ) -> ModelResponse: """ Transforms Code Assist API response to standard LiteLLM format. @@ -168,7 +171,7 @@ class GoogleCodeAssistConfig(VertexGeminiConfig): return super().transform_response( model=model, - raw_response=ParsedJSONResponseAdapter(gemini_response), + raw_response=ParsedJSONResponseAdapter(gemini_response), # type: ignore[arg-type] model_response=model_response, logging_obj=logging_obj, request_data=request_data, diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index fcc1073fe62..a3c6637408b 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -426,9 +426,9 @@ class VertexBase: ) if gemini_oauth_token: - auth_header = {"Authorization": f"Bearer {gemini_oauth_token}"} + auth_header = {"Authorization": f"Bearer {gemini_oauth_token}"} # type: ignore[assignment] if gemini_auth_data and gemini_auth_data.get("project_id"): - auth_header["x-goog-user-project"] = gemini_auth_data[ + auth_header["x-goog-user-project"] = gemini_auth_data[ # type: ignore[index] "project_id" ] elif gemini_api_key is not None: @@ -505,9 +505,9 @@ class VertexBase: stream=stream, ) if gemini_oauth_token: - auth_header = {"Authorization": f"Bearer {gemini_oauth_token}"} + auth_header = {"Authorization": f"Bearer {gemini_oauth_token}"} # type: ignore[assignment] if gemini_auth_data and gemini_auth_data.get("project_id"): - auth_header["x-goog-user-project"] = gemini_auth_data["project_id"] + auth_header["x-goog-user-project"] = gemini_auth_data["project_id"] # type: ignore[index] elif gemini_api_key is None: raise ValueError( "Missing gemini_api_key. Please set `GEMINI_API_KEY` or `GEMINI_OAUTH_TOKEN`." diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index ed49c146210..cf1a011032e 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1105,6 +1105,29 @@ "realtime": true } }, + "google_code_assist": { + "display_name": "Google Code Assist (`google_code_assist`)", + "url": "https://docs.litellm.ai/docs/providers/google_code_assist", + "endpoints": { + "chat_completions": true, + "messages": false, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "interactions": false, + "a2a": false, + "vector_stores_search": false, + "count_tokens": false, + "rag_ingest": false, + "realtime": false, + "generateContent": false + } + }, "gemini": { "display_name": "Google AI Studio - Gemini (`gemini`)", "url": "https://docs.litellm.ai/docs/providers/gemini", diff --git a/tests/proxy_unit_tests/test_google_gemini_proxy_request.py b/tests/proxy_unit_tests/test_google_gemini_proxy_request.py index dbe30037313..8005dd20845 100644 --- a/tests/proxy_unit_tests/test_google_gemini_proxy_request.py +++ b/tests/proxy_unit_tests/test_google_gemini_proxy_request.py @@ -419,7 +419,7 @@ async def test_gemini_custom_api_base_proxy_integration(): print(f"✅ Custom API base streaming URL test passed: {result_url_streaming}") # Test case 3: Error handling - missing API key - with pytest.raises(ValueError, match="Missing Gemini API key"): + with pytest.raises(ValueError, match="Missing gemini_api_key"): vertex_base._check_custom_proxy( api_base=custom_api_base, custom_llm_provider="gemini", diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py index 88aac07a0c9..dbd4581cf9a 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py @@ -821,7 +821,7 @@ class TestVertexBase: if custom_llm_provider == "gemini" and api_base and gemini_api_key is None: # Test case 5: Should raise ValueError for Gemini without API key - with pytest.raises(ValueError, match="Missing Gemini API key"): + with pytest.raises(ValueError, match="Missing gemini_api_key"): vertex_base._check_custom_proxy( api_base=api_base, custom_llm_provider=custom_llm_provider,