Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/flaky-e2e-tests-d022f6

This commit is contained in:
Yuneng Jiang 2026-08-31 15:57:51 -07:00
commit 3eb1eee1fa
No known key found for this signature in database
63 changed files with 5781 additions and 477 deletions

View file

@ -103,6 +103,7 @@ jobs:
tests/test_litellm/completion_extras
tests/test_litellm/compression
tests/test_litellm/containers
tests/test_litellm/endpoints
tests/test_litellm/experimental_mcp_client
tests/test_litellm/models
tests/test_litellm/repositories

View file

@ -86,6 +86,7 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
"/comprehendmedical",
"/cohere/",
"/gemini/",
"/gigachat/",
"/google/",
"/vertex_ai/",
"/vertex-ai/",

View file

@ -806,6 +806,7 @@ openai_compatible_endpoints: Final[list] = [
"https://api.meta.ai/v1",
"https://api.cognition.ai/v1",
"https://api.scx.ai/v1",
"https://gigachat.devices.sberbank.ru/api/v1",
]

View file

@ -1,10 +1,14 @@
from collections.abc import Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, cast
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.constants import OPENAI_CHAT_COMPLETION_PARAMS
if TYPE_CHECKING:
from litellm import Logging as LiteLLMLoggingObj
from litellm.types.llms.openai import HttpxBinaryResponseContent
from litellm.types.llms.openai import ChatCompletionUserMessage, HttpxBinaryResponseContent
from litellm.types.utils import ModelResponse
@ -16,7 +20,42 @@ def _completion_response_cost(model_response: "ModelResponse") -> float | None:
return response_cost if isinstance(response_cost, float) else None
GEMINI_TTS_CHAT_AUDIO_FORMAT: Final = "pcm16"
class ChatAudioParam(TypedDict):
voice: ReadOnly[str]
format: ReadOnly[NotRequired[str]]
class SpeechToCompletionBridgeTransformationHandler:
def _chat_completion_params(self, optional_params: Mapping[str, object]) -> Mapping[str, object]:
return MappingProxyType(
{
param: value
for param, value in optional_params.items()
if param in OPENAI_CHAT_COMPLETION_PARAMS and param != "response_format"
}
)
def _chat_audio_format(self, model: str, optional_params: Mapping[str, object]) -> str | None:
if self._is_gemini_tts_model(model):
return GEMINI_TTS_CHAT_AUDIO_FORMAT
response_format: Final = optional_params.get("response_format")
return response_format if isinstance(response_format, str) else None
def _chat_audio_param(
self, model: str, voice: str | Mapping[str, object] | None, optional_params: Mapping[str, object]
) -> ChatAudioParam | None:
if not isinstance(voice, str):
return None
audio_format: Final = self._chat_audio_format(model, optional_params)
if audio_format is None:
voice_only: Final[ChatAudioParam] = {"voice": voice}
return voice_only
audio: Final[ChatAudioParam] = {"voice": voice, "format": audio_format}
return audio
def transform_request(
self,
model: str,
@ -28,36 +67,19 @@ class SpeechToCompletionBridgeTransformationHandler:
litellm_logging_obj: "LiteLLMLoggingObj",
custom_llm_provider: str,
) -> dict:
passed_optional_params: Final = {}
for op in optional_params:
if op in OPENAI_CHAT_COMPLETION_PARAMS:
passed_optional_params[op] = optional_params[op]
if voice is not None:
if isinstance(voice, str):
passed_optional_params["audio"] = {"voice": voice}
if "response_format" in optional_params:
passed_optional_params["audio"]["format"] = optional_params["response_format"]
return_kwargs = {
user_message: Final[ChatCompletionUserMessage] = {"role": "user", "content": input}
return_kwargs: Final = {
"model": model,
"messages": [
{
"role": "user",
"content": input,
}
],
"messages": [user_message],
"modalities": ["audio"],
**passed_optional_params,
**self._chat_completion_params(optional_params),
"audio": self._chat_audio_param(model, voice, optional_params),
**litellm_params,
"headers": headers,
"litellm_logging_obj": litellm_logging_obj,
"custom_llm_provider": custom_llm_provider,
}
# filter out None values
return_kwargs = {k: v for k, v in return_kwargs.items() if v is not None}
return return_kwargs
return {k: v for k, v in return_kwargs.items() if v is not None}
def _convert_pcm16_to_wav(self, pcm_data: bytes, sample_rate: int = 24000, channels: int = 1) -> bytes:
"""

View file

@ -50,6 +50,9 @@ OPTIONAL_KWARGS_KEYS: Final = (
"vertex_ai_project",
"vertex_ai_location",
"vertex_ai_credentials",
"gigachat_scope",
"gigachat_auth_url",
"gigachat_access_token",
"tpm",
"rpm",
"itpm",

View file

@ -369,6 +369,9 @@ def get_llm_provider(
elif endpoint == "https://api.meta.ai/v1":
custom_llm_provider = "meta"
dynamic_api_key = get_secret_str("META_API_KEY")
elif endpoint == "https://gigachat.devices.sberbank.ru/api/v1":
custom_llm_provider = "gigachat"
dynamic_api_key = get_secret_str("GIGACHAT_API_KEY")
elif (json_provider := JSONProviderRegistry.get_by_base_url(endpoint)) is not None:
custom_llm_provider = json_provider.slug
dynamic_api_key = api_key if api_key is not None else get_secret_str(json_provider.api_key_env)
@ -867,6 +870,9 @@ def _get_openai_compatible_provider_info(
# Manus is OpenAI compatible for responses API
api_base = api_base or get_secret_str("MANUS_API_BASE") or "https://api.manus.im"
dynamic_api_key = api_key or get_secret_str("MANUS_API_KEY")
elif custom_llm_provider == "gigachat":
api_base = api_base or get_secret_str("GIGACHAT_API_BASE") or "https://gigachat.devices.sberbank.ru/api/v1"
dynamic_api_key = api_key or get_secret_str("GIGACHAT_API_KEY")
if api_base is not None and not isinstance(api_base, str):
raise Exception(f"api base needs to be a string. api_base={api_base}")

View file

@ -2141,6 +2141,9 @@ class Logging(LiteLLMLoggingBaseClass):
logging_result: Final = self.normalize_logging_result(result=result)
if isinstance(result, Response) and isinstance(logging_result, (ModelResponse, EmbeddingResponse)):
result = logging_result
if standard_logging_object is None and result is not None and self.stream is not True:
if self._is_recognized_call_type_for_logging(logging_result=logging_result) or isinstance(
logging_result, (dict, list)
@ -6152,7 +6155,10 @@ def get_standard_logging_object_payload(
def emit_standard_logging_payload(payload: StandardLoggingPayload):
if os.getenv("LITELLM_PRINT_STANDARD_LOGGING_PAYLOAD"):
print(json.dumps(payload, indent=4), flush=True) # noqa: T201
try:
print(json.dumps(payload, indent=4, default=str), flush=True) # noqa: T201
except Exception as e: # noqa: BLE001 # Safe catch-all for verbose logging
verbose_logger.exception("Error serializing standard logging payload for debug output: %s", e)
def get_standard_logging_metadata(

View file

@ -15,9 +15,11 @@ API Documentation: https://developers.sber.ru/docs/ru/gigachat/api/overview
from .chat.transformation import GigaChatConfig, GigaChatError
from .embedding.transformation import GigaChatEmbeddingConfig
from .passthrough.transformation import GigaChatPassthroughConfig
__all__ = [
__all__ = (
"GigaChatConfig",
"GigaChatEmbeddingConfig",
"GigaChatError",
]
"GigaChatPassthroughConfig",
)

View file

@ -7,6 +7,7 @@ Based on official GigaChat SDK authentication flow.
import time
import uuid
from collections.abc import Mapping
from typing import Final
import httpx
@ -16,7 +17,7 @@ from litellm.caching.caching import InMemoryCache
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.custom_httpx.http_handler import (
HTTPHandler,
_get_httpx_client,
_get_httpx_client, # pyright: ignore[reportPrivateUsage] # house cached-client factory has no public alias
get_async_httpx_client,
)
from litellm.secret_managers.main import get_secret_str
@ -63,6 +64,7 @@ def get_access_token(
credentials: str | None = None,
scope: str | None = None,
auth_url: str | None = None,
litellm_params: Mapping[str, object] | None = None,
) -> str:
"""
Get valid access token, using cache if available.
@ -78,71 +80,88 @@ def get_access_token(
Raises:
GigaChatAuthError: If authentication fails
"""
credentials = credentials or _get_credentials()
if not credentials:
if not litellm_params:
litellm_params = {} # mutable-ok: empty dict default; rebind-ok: provide default
access_token: Final = litellm_params.get("gigachat_access_token") or get_secret_str("GIGACHAT_ACCESS_TOKEN")
if access_token:
return access_token
effective_credentials: Final = credentials or _get_credentials()
if not effective_credentials:
raise GigaChatAuthError(
status_code=401,
message="GigaChat credentials not provided. Set GIGACHAT_CREDENTIALS or GIGACHAT_API_KEY environment variable.",
)
scope = scope or _get_scope()
auth_url = auth_url or _get_auth_url()
effective_scope: Final = scope or litellm_params.get("gigachat_scope") or _get_scope()
effective_auth_url: Final = auth_url or litellm_params.get("gigachat_auth_url") or _get_auth_url()
# Check cache
cache_key: Final = f"gigachat_token:{credentials[:16]}"
cache_key: Final = f"gigachat_token:{effective_credentials[:16]}"
cached: Final = _token_cache.get_cache(cache_key)
if cached:
token, expires_at = cached
_token, _expires_at = cached
# Check if token is still valid (with buffer)
if time.time() * 1000 < expires_at - TOKEN_EXPIRY_BUFFER_MS:
if time.time() * 1000 < _expires_at - TOKEN_EXPIRY_BUFFER_MS:
verbose_logger.debug("Using cached GigaChat access token")
return token
return _token
# Request new token
token, expires_at = _request_token_sync(credentials, scope, auth_url)
new_token, new_expires_at = _request_token_sync(effective_credentials, effective_scope, effective_auth_url) # pyright: ignore[reportArgumentType] # credential keys may be broader than str
# Cache token
ttl_seconds: Final = max(0, (expires_at - TOKEN_EXPIRY_BUFFER_MS - time.time() * 1000) / 1000)
if ttl_seconds > 0:
_token_cache.set_cache(cache_key, (token, expires_at), ttl=ttl_seconds)
if new_expires_at:
# Cache token
ttl_seconds: Final = max(0, (new_expires_at - TOKEN_EXPIRY_BUFFER_MS - time.time() * 1000) / 1000)
if ttl_seconds > 0:
_token_cache.set_cache(cache_key, (new_token, new_expires_at), ttl=ttl_seconds)
return token
return new_token
async def get_access_token_async(
credentials: str | None = None,
scope: str | None = None,
auth_url: str | None = None,
litellm_params: Mapping[str, object] | None = None,
) -> str:
"""Async version of get_access_token."""
credentials = credentials or _get_credentials()
if not credentials:
if not litellm_params:
litellm_params = {} # mutable-ok: empty dict default; rebind-ok: provide default
access_token: Final = litellm_params.get("gigachat_access_token") or get_secret_str("GIGACHAT_ACCESS_TOKEN")
if access_token:
return access_token
effective_credentials: Final = credentials or _get_credentials()
if not effective_credentials:
raise GigaChatAuthError(
status_code=401,
message="GigaChat credentials not provided. Set GIGACHAT_CREDENTIALS or GIGACHAT_API_KEY environment variable.",
)
scope = scope or _get_scope()
auth_url = auth_url or _get_auth_url()
effective_scope: Final = scope or litellm_params.get("gigachat_scope") or _get_scope()
effective_auth_url: Final = auth_url or litellm_params.get("gigachat_auth_url") or _get_auth_url()
# Check cache
cache_key: Final = f"gigachat_token:{credentials[:16]}"
cache_key: Final = f"gigachat_token:{effective_credentials[:16]}"
cached: Final = _token_cache.get_cache(cache_key)
if cached:
token, expires_at = cached
if time.time() * 1000 < expires_at - TOKEN_EXPIRY_BUFFER_MS:
_token, _expires_at = cached
if time.time() * 1000 < _expires_at - TOKEN_EXPIRY_BUFFER_MS:
verbose_logger.debug("Using cached GigaChat access token")
return token
return _token
# Request new token
token, expires_at = await _request_token_async(credentials, scope, auth_url)
new_token, new_expires_at = await _request_token_async(effective_credentials, effective_scope, effective_auth_url) # pyright: ignore[reportArgumentType] # credential keys may be broader than str
# Cache token
ttl_seconds: Final = max(0, (expires_at - TOKEN_EXPIRY_BUFFER_MS - time.time() * 1000) / 1000)
if ttl_seconds > 0:
_token_cache.set_cache(cache_key, (token, expires_at), ttl=ttl_seconds)
if new_expires_at:
# Cache token
ttl_seconds: Final = max(0, (new_expires_at - TOKEN_EXPIRY_BUFFER_MS - time.time() * 1000) / 1000)
if ttl_seconds > 0:
_token_cache.set_cache(cache_key, (new_token, new_expires_at), ttl=ttl_seconds)
return token
return new_token
def _request_token_sync(
@ -154,7 +173,7 @@ def _request_token_sync(
Request new access token from GigaChat OAuth endpoint (sync).
Returns:
Tuple of (access_token, expires_at_ms)
tuple of (access_token, expires_at_ms)
"""
headers: Final = {
"Authorization": f"Basic {credentials}",
@ -169,7 +188,7 @@ def _request_token_sync(
client: Final = _get_http_client()
response: Final = client.post(auth_url, headers=headers, data=data, timeout=30)
response.raise_for_status()
return _parse_token_response(response)
return _parse_token_response(response) # pyright: ignore[reportArgumentType] # httpx Response may be None at type level
except httpx.HTTPStatusError as e:
raise GigaChatAuthError(
status_code=e.response.status_code,
@ -204,7 +223,7 @@ async def _request_token_async(
)
response: Final = await client.post(auth_url, headers=headers, data=data, timeout=30)
response.raise_for_status()
return _parse_token_response(response)
return _parse_token_response(response) # pyright: ignore[reportArgumentType] # httpx Response may be None at type level
except httpx.HTTPStatusError as e:
raise GigaChatAuthError(
status_code=e.response.status_code,
@ -223,7 +242,7 @@ def _parse_token_response(response: httpx.Response) -> tuple[str, int]:
# GigaChat returns either 'tok'/'exp' or 'access_token'/'expires_at'
access_token: Final = data.get("tok") or data.get("access_token")
expires_at = data.get("exp") or data.get("expires_at")
expires_at_raw: Final = data.get("exp") or data.get("expires_at")
if not access_token:
raise GigaChatAuthError(
@ -232,8 +251,11 @@ def _parse_token_response(response: httpx.Response) -> tuple[str, int]:
)
# expires_at is in milliseconds
if isinstance(expires_at, str):
expires_at = int(expires_at)
expires_at: int # rebind-ok: conditionally assigned from str or int
if isinstance(expires_at_raw, str):
expires_at = int(expires_at_raw) # rebind-ok: conditionally assigned from str or int
else:
expires_at = expires_at_raw # pyright: ignore[reportAssignmentType] # raw value is int or str; converted above; rebind-ok: conditionally assigned from str or int
verbose_logger.debug("GigaChat access token obtained successfully")
return access_token, expires_at

View file

@ -5,8 +5,8 @@ GigaChat Chat Module
from .streaming import GigaChatModelResponseIterator
from .transformation import GigaChatConfig, GigaChatError
__all__ = [
__all__ = (
"GigaChatConfig",
"GigaChatError",
"GigaChatModelResponseIterator",
]
)

View file

@ -4,13 +4,15 @@ GigaChat Streaming Response Handler
import json
import uuid
from collections.abc import Mapping, Sequence
from typing import Any, Final
from litellm.llms.gigachat.utils import convert_usage
from litellm.types.llms.openai import (
ChatCompletionToolCallChunk,
ChatCompletionToolCallFunctionChunk,
)
from litellm.types.utils import GenericStreamingChunk
from litellm.types.utils import ChatCompletionUsageBlock, GenericStreamingChunk
class GigaChatModelResponseIterator:
@ -26,14 +28,9 @@ class GigaChatModelResponseIterator:
self.response_iterator = self.streaming_response
self.json_mode = json_mode
def chunk_parser(self, chunk: dict) -> GenericStreamingChunk:
def chunk_parser(self, chunk: Mapping[str, object]) -> GenericStreamingChunk:
"""Parse a single streaming chunk from GigaChat."""
text = ""
tool_use: ChatCompletionToolCallChunk | None = None
is_finished = False
finish_reason: str | None = None
choices: Final = chunk.get("choices", [])
choices: Sequence = chunk.get("choices") or () # mutable-ok: tuple literal as default
if not choices:
return GenericStreamingChunk(
text="",
@ -45,40 +42,63 @@ class GigaChatModelResponseIterator:
)
choice: Final = choices[0]
delta: Final = choice.get("delta", {})
finish_reason = choice.get("finish_reason")
delta: Mapping[str, object] = choice.get("delta") or {} # mutable-ok: empty dict default for get
chunk_finish_reason: Final = choice.get("finish_reason")
# Extract text content
text = delta.get("content", "") or ""
text: Final = delta.get("content", "") or ""
usage_block: ChatCompletionUsageBlock | None = None # rebind-ok: conditionally assigned after stop detection
tool_use: ChatCompletionToolCallChunk | None = None # rebind-ok: conditionally assigned on function_call
finish_reason: str | None = chunk_finish_reason
# Handle function_call in stream
if finish_reason == "function_call" and delta.get("function_call"):
func_call: Final = delta["function_call"]
args = func_call.get("arguments", {})
if isinstance(args, dict):
args = json.dumps(args, ensure_ascii=False)
raw_function_call: Final = delta.get("function_call")
if chunk_finish_reason == "function_call" and isinstance(raw_function_call, Mapping) and raw_function_call:
func_call: Final[Mapping[str, object]] = raw_function_call
args_raw: Final[object] = func_call.get("arguments") or {}
args_str: str # rebind-ok: conditionally assigned from dict or str
if isinstance(args_raw, dict):
args_str = json.dumps(args_raw, ensure_ascii=False) # rebind-ok: build from dict
else:
args_str = str(args_raw)
name_raw: Final = func_call.get("name")
tool_use = ChatCompletionToolCallChunk(
id=f"call_{uuid.uuid4().hex[:24]}",
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=func_call.get("name", ""),
arguments=args,
name=name_raw if isinstance(name_raw, str) else "",
arguments=args_str,
),
index=0,
)
finish_reason = "tool_calls"
if finish_reason is not None:
is_finished = True
usage_data: Final = chunk.get("usage") or {} # mutable-ok: empty dict default
if usage_data and isinstance(usage_data, dict):
validated_usage: Final = {k: int(v) for k, v in usage_data.items()}
usage = convert_usage(validated_usage)
_prompt_details: dict | None = (
usage.prompt_tokens_details.model_dump() if usage.prompt_tokens_details else None
) # rebind-ok: conditional
_completion_details: dict | None = (
usage.completion_tokens_details.model_dump() if usage.completion_tokens_details else None
) # rebind-ok: conditional
usage_block = ChatCompletionUsageBlock( # pyright: ignore[reportCallIssue] # TypedDict kwarg constructor
prompt_tokens=usage.prompt_tokens,
completion_tokens=usage.completion_tokens,
total_tokens=usage.total_tokens,
prompt_tokens_details=_prompt_details,
completion_tokens_details=_completion_details,
)
return GenericStreamingChunk(
text=text,
text=str(text),
tool_use=tool_use,
is_finished=is_finished,
is_finished=chunk_finish_reason is not None,
finish_reason=finish_reason or "",
usage=None,
usage=usage_block,
index=choice.get("index", 0),
)

View file

@ -4,19 +4,22 @@ GigaChat Chat Transformation
Transforms OpenAI-format requests to GigaChat format and back.
"""
from __future__ import annotations
import json
import time
import uuid
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final
import httpx
from litellm._logging import verbose_logger
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.gigachat.utils import convert_usage, get_api_base
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import Choices, Message, ModelResponse, Usage
from litellm.types.utils import Choices, Message, ModelResponse
from ..authenticator import get_access_token
from ..file_handler import upload_file_sync
@ -30,9 +33,6 @@ if TYPE_CHECKING:
else:
LiteLLMLoggingObj = Any
# GigaChat API endpoint
GIGACHAT_BASE_URL: Final = "https://gigachat.devices.sberbank.ru/api/v1"
def is_valid_json(value: str) -> bool:
"""Checks whether the value passed is a valid serialized JSON string"""
@ -90,30 +90,30 @@ class GigaChatConfig(BaseConfig):
api_base: str | None,
api_key: str | None,
model: str,
optional_params: dict,
litellm_params: dict,
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
stream: bool | None = None,
) -> str:
"""Get complete API URL for chat completions."""
base: Final = api_base or get_secret_str("GIGACHAT_API_BASE") or GIGACHAT_BASE_URL
base: Final = get_api_base(api_base)
return f"{base}/chat/completions"
def validate_environment(
self,
headers: dict,
headers: dict, # mutable-ok: mutates in place per GigaChat OAuth setup
model: str,
messages: list[AllMessageValues],
optional_params: dict,
litellm_params: dict,
messages: Sequence[AllMessageValues],
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
api_key: str | None = None,
api_base: str | None = None,
) -> dict:
) -> dict: # mutable-ok: base class contract returns dict for httpx
"""
Set up headers with OAuth token.
"""
# Get access token
credentials: Final = api_key or get_secret_str("GIGACHAT_CREDENTIALS") or get_secret_str("GIGACHAT_API_KEY")
access_token: Final = get_access_token(credentials=credentials)
access_token: Final = get_access_token(credentials=credentials, litellm_params=litellm_params)
# Store credentials for image uploads
self._current_credentials = credentials
@ -125,9 +125,9 @@ class GigaChatConfig(BaseConfig):
return headers
def get_supported_openai_params(self, model: str) -> list[str]:
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: base class contract returns list
"""Return list of supported OpenAI parameters."""
return [
return [ # mutable-ok: base class contract returns list
"stream",
"temperature",
"top_p",
@ -143,11 +143,11 @@ class GigaChatConfig(BaseConfig):
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
non_default_params: Mapping[str, object],
optional_params: dict, # mutable-ok: mutated in place per GigaChat mapping
model: str,
drop_params: bool,
) -> dict:
) -> dict: # mutable-ok: base class contract returns dict
"""Map OpenAI parameters to GigaChat parameters."""
for param, value in non_default_params.items():
if param == "stream":
@ -167,42 +167,50 @@ class GigaChatConfig(BaseConfig):
pass
elif param == "tools":
# Convert tools to functions format
optional_params["functions"] = self._convert_tools_to_functions(value)
if isinstance(value, Sequence):
optional_params["functions"] = self._convert_tools_to_functions(value)
elif param == "tool_choice":
# Map OpenAI tool_choice to GigaChat function_call
mapped_choice = self._map_tool_choice(value)
if mapped_choice is not None:
optional_params["function_call"] = mapped_choice
if isinstance(value, (str, Mapping)):
mapped_choice = self._map_tool_choice(value)
if mapped_choice is not None:
optional_params["function_call"] = mapped_choice
elif param == "functions":
optional_params["functions"] = value
elif param == "function_call":
optional_params["function_call"] = value
elif param == "response_format":
# Handle structured output via function calling
if value.get("type") == "json_schema":
if isinstance(value, Mapping) and value.get("type") == "json_schema":
json_schema = value.get("json_schema", {})
schema_name = json_schema.get("name", "structured_output")
schema = json_schema.get("schema", {})
function_def = {
function_def = { # mutable-ok: request payload for httpx
"name": schema_name,
"description": f"Output structured response: {schema_name}",
"parameters": schema,
}
if "functions" not in optional_params:
optional_params["functions"] = []
optional_params["functions"].append(function_def)
optional_params["function_call"] = {"name": schema_name}
existing_functions = optional_params.get("functions")
optional_params["functions"] = [
*(
existing_functions
if isinstance(existing_functions, Sequence) and not isinstance(existing_functions, str)
else ()
),
function_def,
]
optional_params["function_call"] = {"name": schema_name} # mutable-ok: request payload
optional_params["_structured_output"] = True
return optional_params
def _convert_tools_to_functions(self, tools: list[dict]) -> list[dict]:
def _convert_tools_to_functions(self, tools: Sequence) -> Sequence[dict]:
"""Convert OpenAI tools format to GigaChat functions format."""
functions: Final = []
functions: Final[list[dict]] = [] # mutable-ok: accumulator for building functions list
for tool in tools:
if tool.get("type") == "function":
if isinstance(tool, dict) and tool.get("type") == "function":
func = tool.get("function", {})
functions.append(
{
@ -213,7 +221,7 @@ class GigaChatConfig(BaseConfig):
)
return functions
def _map_tool_choice(self, tool_choice: str | dict) -> str | dict | None:
def _map_tool_choice(self, tool_choice: str | Mapping[str, object]) -> str | Mapping[str, object] | None:
"""
Map OpenAI tool_choice to GigaChat function_call format.
@ -246,8 +254,9 @@ class GigaChatConfig(BaseConfig):
# OpenAI format: {"type": "function", "function": {"name": "func_name"}}
# GigaChat format: {"name": "func_name"}
if tool_choice.get("type") == "function":
func_name: Final = tool_choice.get("function", {}).get("name")
if func_name:
function_spec: Final = tool_choice.get("function")
func_name: Final = function_spec.get("name") if isinstance(function_spec, Mapping) else None
if isinstance(func_name, str) and func_name:
return {"name": func_name}
# Default to None (don't set function_call)
@ -273,20 +282,51 @@ class GigaChatConfig(BaseConfig):
verbose_logger.error("Failed to upload image: %s", e)
return None
def _transform_list_content(self, content: Sequence) -> tuple[str, Sequence[str]]:
"""
Extract text and image attachments from a multimodal message content list.
Args:
content: List of content parts (OpenAI multimodal format)
Returns:
Tuple of (combined text, list of attachment file ids)
"""
texts: Final[list[str]] = [] # mutable-ok: accumulator
attachments: Final[list[str]] = [] # mutable-ok: accumulator
for part in content:
if isinstance(part, dict):
if part.get("type") == "text":
texts.append(part.get("text", ""))
elif part.get("type") == "image_url":
# Extract image URL and upload to GigaChat
image_url: object = part.get("image_url", {})
upload_url: str
if isinstance(image_url, str):
upload_url = image_url
else:
upload_url = str(image_url.get("url", "")) if isinstance(image_url, dict) else ""
if upload_url:
file_id = self._upload_image(upload_url)
if file_id:
attachments.append(file_id)
text: Final = "\n".join(texts) if texts else ""
return text, attachments
def transform_request(
self,
model: str,
messages: list[AllMessageValues],
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
messages: Sequence[AllMessageValues],
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
headers: Mapping[str, object],
) -> dict: # mutable-ok: request payload sent to httpx
"""Transform OpenAI request to GigaChat format."""
# Transform messages
giga_messages: Final = self._transform_messages(messages)
# Build request
request_data: Final = {
request_data: Final[dict[str, object]] = {
"model": model.replace("gigachat/", ""),
"messages": giga_messages,
}
@ -311,9 +351,9 @@ class GigaChatConfig(BaseConfig):
return request_data
def _transform_messages(self, messages: list[AllMessageValues]) -> list[dict]:
def _transform_messages(self, messages: Sequence[AllMessageValues]) -> Sequence[dict]:
"""Transform OpenAI messages to GigaChat format."""
transformed: Final = []
transformed: Final[list[dict]] = [] # mutable-ok: accumulator for building transformed messages
for i, msg in enumerate(messages):
message = dict(msg)
@ -341,24 +381,7 @@ class GigaChatConfig(BaseConfig):
# Handle list content (multimodal) - extract text and images
content = message.get("content")
if isinstance(content, list):
texts = []
attachments = []
for part in content:
if isinstance(part, dict):
if part.get("type") == "text":
texts.append(part.get("text", ""))
elif part.get("type") == "image_url":
# Extract image URL and upload to GigaChat
image_url = part.get("image_url", {})
if isinstance(image_url, str):
url = image_url
else:
url = image_url.get("url", "")
if url:
file_id = self._upload_image(url)
if file_id:
attachments.append(file_id)
message["content"] = "\n".join(texts) if texts else ""
message["content"], attachments = self._transform_list_content(content)
if attachments:
message["attachments"] = attachments
@ -393,7 +416,7 @@ class GigaChatConfig(BaseConfig):
messages: list[AllMessageValues],
optional_params: dict,
litellm_params: dict,
encoding: "tiktoken.Encoding | None",
encoding: tiktoken.Encoding | None,
api_key: str | None = None,
json_mode: bool | None = None,
) -> ModelResponse:
@ -408,7 +431,7 @@ class GigaChatConfig(BaseConfig):
is_structured_output: Final = optional_params.get("_structured_output", False)
choices: Final = []
choices: Final[list[Choices]] = [] # mutable-ok: accumulator for building response choices
for choice in response_json.get("choices", []):
message_data = choice.get("message", {})
finish_reason = choice.get("finish_reason", "stop")
@ -462,11 +485,7 @@ class GigaChatConfig(BaseConfig):
# Build usage
usage_data: Final = response_json.get("usage", {})
usage: Final = Usage(
prompt_tokens=usage_data.get("prompt_tokens", 0),
completion_tokens=usage_data.get("completion_tokens", 0),
total_tokens=usage_data.get("total_tokens", 0),
)
usage: Final = convert_usage(usage_data)
model_response.id = response_json.get("id", f"chatcmpl-{uuid.uuid4().hex[:12]}")
model_response.created = response_json.get("created", int(time.time()))

View file

@ -5,6 +5,8 @@ Transforms OpenAI /v1/embeddings format to GigaChat format.
API Documentation: https://developers.sber.ru/docs/ru/gigachat/api/reference/rest/post-embeddings
"""
from __future__ import annotations
import types
from typing import Final
@ -14,14 +16,12 @@ from litellm import LlmProviders
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
from litellm.llms.gigachat.utils import get_api_base
from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues
from litellm.types.utils import EmbeddingResponse
from ..authenticator import get_access_token
# GigaChat API endpoint
GIGACHAT_BASE_URL: Final = "https://gigachat.devices.sberbank.ru/api/v1"
class GigaChatEmbeddingError(BaseLLMException):
"""GigaChat Embedding API error."""
@ -78,9 +78,9 @@ class GigaChatEmbeddingConfig(BaseEmbeddingConfig):
Returns provider info for GigaChat.
Returns:
Tuple of (custom_llm_provider, api_base, dynamic_api_key)
tuple of (custom_llm_provider, api_base, dynamic_api_key)
"""
api_base = api_base or GIGACHAT_BASE_URL
api_base = get_api_base(api_base)
return LlmProviders.GIGACHAT.value, api_base, api_key
def get_complete_url(
@ -93,7 +93,7 @@ class GigaChatEmbeddingConfig(BaseEmbeddingConfig):
stream: bool | None = None,
) -> str:
"""Get the complete URL for embeddings endpoint."""
base: Final = api_base or GIGACHAT_BASE_URL
base: Final = get_api_base(api_base)
return f"{base}/embeddings"
def transform_embedding_request(
@ -114,14 +114,12 @@ class GigaChatEmbeddingConfig(BaseEmbeddingConfig):
"""
# Normalize input to list
if isinstance(input, str):
input_list: list = [input]
elif isinstance(input, list):
input_list = input
input_list: list = [input] # rebind-ok: locally scoped conversion
else:
input_list = [input]
input_list = input
# Remove gigachat/ prefix from model if present
model = model.removeprefix("gigachat/")
model = model.removeprefix("gigachat/") # rebind-ok: parameter reassignment for normalization
return {
"model": model,
@ -191,7 +189,7 @@ class GigaChatEmbeddingConfig(BaseEmbeddingConfig):
Set up headers with OAuth token for GigaChat.
"""
# Get access token via OAuth
access_token: Final = get_access_token(api_key)
access_token: Final = get_access_token(credentials=api_key, litellm_params=litellm_params)
default_headers: Final = {
"Content-Type": "application/json",

View file

@ -9,6 +9,7 @@ import base64
import hashlib
import re
import uuid
from collections.abc import Mapping
from typing import Final
from litellm._logging import verbose_logger
@ -16,13 +17,11 @@ from litellm.llms.custom_httpx.http_handler import (
_get_httpx_client,
get_async_httpx_client,
)
from litellm.llms.gigachat.utils import get_api_base
from litellm.types.utils import LlmProviders
from .authenticator import get_access_token, get_access_token_async
# GigaChat API endpoint
GIGACHAT_BASE_URL: Final = "https://gigachat.devices.sberbank.ru/api/v1"
# Simple in-memory cache for file IDs
_file_cache: Final[dict[str, str]] = {}
@ -82,6 +81,7 @@ def upload_file_sync(
image_url: str,
credentials: str | None = None,
api_base: str | None = None,
litellm_params: Mapping[str, object] | None = None,
) -> str | None:
"""
Upload file to GigaChat and return file_id (sync).
@ -114,10 +114,10 @@ def upload_file_sync(
filename: Final = f"{uuid.uuid4()}.{ext}"
# Get access token
access_token: Final = get_access_token(credentials)
access_token: Final = get_access_token(credentials=credentials, litellm_params=litellm_params)
# Upload to GigaChat
base_url: Final = api_base or GIGACHAT_BASE_URL
base_url: Final = get_api_base(api_base)
upload_url: Final = f"{base_url}/files"
client: Final = _get_httpx_client(params={"ssl_verify": False})
@ -147,6 +147,7 @@ async def upload_file_async(
image_url: str,
credentials: str | None = None,
api_base: str | None = None,
litellm_params: Mapping[str, object] | None = None,
) -> str | None:
"""
Upload file to GigaChat and return file_id (async).
@ -179,10 +180,10 @@ async def upload_file_async(
filename: Final = f"{uuid.uuid4()}.{ext}"
# Get access token
access_token: Final = await get_access_token_async(credentials)
access_token: Final = await get_access_token_async(credentials=credentials, litellm_params=litellm_params)
# Upload to GigaChat
base_url: Final = api_base or GIGACHAT_BASE_URL
base_url: Final = get_api_base(api_base)
upload_url: Final = f"{base_url}/files"
client: Final = get_async_httpx_client(

View file

@ -0,0 +1,7 @@
"""
GigaChat passthrough Module
"""
from .transformation import GigaChatPassthroughConfig
__all__ = ("GigaChatPassthroughConfig",)

View file

@ -0,0 +1,213 @@
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Final
import httpx
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig
from litellm.llms.gigachat.authenticator import get_access_token
from litellm.llms.gigachat.chat.streaming import GigaChatModelResponseIterator
from litellm.llms.gigachat.utils import GIGACHAT_BASE_URL
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import EmbeddingResponse
if TYPE_CHECKING:
from httpx import URL, Response
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.utils import CostResponseTypes
class GigaChatPassthroughConfig(BasePassthroughConfig):
def is_streaming_request(self, endpoint: str, request_data: Mapping[str, object]) -> bool:
return request_data.get("stream", False)
def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
endpoint: str,
request_query_params: Mapping[str, object] | None,
litellm_params: Mapping[str, object],
) -> tuple[URL, str]:
"""Get complete API URL for chat completions."""
base_target_url: Final = self.get_api_base(api_base)
if base_target_url is None:
raise Exception("GigaChat api base not found")
complete_url: Final = f"{base_target_url}/{endpoint.lstrip('/')}"
return (
httpx.URL(complete_url),
base_target_url,
)
def validate_environment(
self,
headers: dict, # mutable-ok: mutates in place to set OAuth headers
model: str,
messages: Sequence[AllMessageValues],
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
api_key: str | None = None,
api_base: str | None = None,
) -> dict: # mutable-ok: base class contract returns dict for httpx
"""
Set up headers with OAuth token.
"""
# Get access token
access_token: Final = get_access_token(credentials=api_key, litellm_params=litellm_params)
headers["Authorization"] = f"Bearer {access_token}" # rebind-ok: mutating for OAuth setup
headers["Content-Type"] = "application/json" # rebind-ok: mutating for OAuth setup
headers["Accept"] = "application/json" # rebind-ok: mutating for OAuth setup
return headers
def logging_non_streaming_response(
self,
model: str,
custom_llm_provider: str,
httpx_response: Response,
request_data: Mapping[str, object],
logging_obj: LiteLLMLoggingObj,
endpoint: str,
) -> CostResponseTypes | None:
from litellm import encoding
from litellm.types.utils import LlmProviders, ModelResponse
from litellm.utils import ProviderConfigManager
# cost tracking only for completions and embeddings
if "completions" in endpoint:
provider_chat_config: Final = ProviderConfigManager.get_provider_chat_config(
provider=LlmProviders(custom_llm_provider),
model=model,
)
if provider_chat_config is None:
raise ValueError(f"No provider config found for model: {model}")
raw_messages: Final = request_data.get("messages")
litellm_model_response: Final = provider_chat_config.transform_response(
model=model,
messages=list(raw_messages)
if isinstance(raw_messages, list)
else [], # mutable-ok: transform_response wants a list
raw_response=httpx_response,
model_response=ModelResponse(),
logging_obj=logging_obj,
optional_params={}, # mutable-ok: empty dict kwarg for transform_response
litellm_params={}, # mutable-ok: empty dict kwarg for transform_response
api_key="",
request_data=dict(request_data), # mutable-ok: transform_response wants a dict
encoding=encoding,
)
return litellm_model_response
if "embeddings" in endpoint:
provider_embedding_config: Final = ProviderConfigManager.get_provider_embedding_config(
provider=LlmProviders(custom_llm_provider),
model=model,
)
if provider_embedding_config is None:
raise ValueError(f"No provider config found for model: {model}")
litellm_embedding_response: Final[EmbeddingResponse] = (
provider_embedding_config.transform_embedding_response(
model=model,
raw_response=httpx_response,
model_response=EmbeddingResponse(),
logging_obj=logging_obj,
optional_params={}, # mutable-ok: empty dict kwarg for transform_embedding_response
api_key="",
request_data=dict(request_data), # mutable-ok: transform_embedding_response wants a dict
litellm_params={}, # mutable-ok: empty dict kwarg for transform_embedding_response
)
)
return litellm_embedding_response
return None
def handle_logging_collected_chunks(
self,
all_chunks: Sequence[str],
litellm_logging_obj: LiteLLMLoggingObj,
model: str,
custom_llm_provider: str,
endpoint: str,
) -> CostResponseTypes | None:
"""
1. Convert all_chunks to a ModelResponseStream
2. combine model_response_stream to model_response
3. Return the model_response
"""
from litellm.litellm_core_utils.streaming_handler import (
convert_generic_chunk_to_model_response_stream,
generic_chunk_has_all_required_fields,
)
from litellm.main import stream_chunk_builder
from litellm.types.utils import ModelResponseStream
all_translated_chunks: Final[list[object]] = [] # mutable-ok: accumulator
for chunk in all_chunks:
chunk = chunk.strip()
if not chunk or chunk == "[DONE]":
continue
chunk = chunk.removeprefix("data: ")
try:
message = json.loads(chunk)
except json.JSONDecodeError:
continue
gigachat_iterator = GigaChatModelResponseIterator(
streaming_response=None,
sync_stream=False,
)
translated_chunk = gigachat_iterator.chunk_parser(chunk=message)
if isinstance(translated_chunk, dict) and generic_chunk_has_all_required_fields( # pyright: ignore[reportUnnecessaryIsInstance] # runtime guard for patched chunk_parser
dict(translated_chunk)
):
chunk_obj = convert_generic_chunk_to_model_response_stream(
translated_chunk # pyright: ignore[reportArgumentType] # validated TypedDict
)
elif isinstance(translated_chunk, ModelResponseStream):
chunk_obj = translated_chunk
else:
continue
all_translated_chunks.append(chunk_obj)
if len(all_translated_chunks) > 0:
return stream_chunk_builder(
chunks=all_translated_chunks,
logging_obj=litellm_logging_obj,
)
return None
@staticmethod
def get_api_base(api_base: str | None = None) -> str | None:
return api_base or get_secret_str("GIGACHAT_API_BASE") or GIGACHAT_BASE_URL
@staticmethod
def get_api_key(
api_key: str | None = None,
) -> str | None:
return api_key or get_secret_str("GIGACHAT_API_KEY")
@staticmethod
def get_base_model(model: str) -> str | None:
return model
def get_models(self, api_key: str | None = None, api_base: str | None = None) -> list[str]:
return list(super().get_models(api_key, api_base))

View file

@ -0,0 +1,26 @@
from collections.abc import Mapping
from typing import Final
from litellm.secret_managers.main import get_secret_str
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
# GigaChat API endpoint
GIGACHAT_BASE_URL: Final = "https://gigachat.devices.sberbank.ru/api/v1"
def convert_usage(usage_data: Mapping[str, int]) -> Usage:
precached_prompt_tokens: Final = usage_data.get("precached_prompt_tokens", 0)
prompt_tokens_details: Final = (
PromptTokensDetailsWrapper(cached_tokens=precached_prompt_tokens) if precached_prompt_tokens > 0 else None
)
return Usage(
prompt_tokens=usage_data.get("prompt_tokens", 0) + precached_prompt_tokens,
completion_tokens=usage_data.get("completion_tokens", 0),
prompt_tokens_details=prompt_tokens_details,
total_tokens=usage_data.get("total_tokens", 0) + precached_prompt_tokens,
)
def get_api_base(api_base: str | None = None) -> str | None:
return api_base or get_secret_str("GIGACHAT_API_BASE") or GIGACHAT_BASE_URL

View file

@ -5507,6 +5507,9 @@ def completion(
tpm=kwargs.get("tpm"),
rpm=kwargs.get("rpm"),
use_xai_oauth=kwargs.get("use_xai_oauth", False),
gigachat_scope=kwargs.get("gigachat_scope"),
gigachat_auth_url=kwargs.get("gigachat_auth_url"),
gigachat_access_token=kwargs.get("gigachat_access_token"),
**{key: kwargs[key] for key in FORWARDED_KWARGS_KEYS if key in kwargs},
)
cast(LiteLLMLoggingObj, logging).update_environment_variables(

View file

@ -24419,7 +24419,7 @@
"supports_response_schema": true,
"supports_vision": true
},
"gigachat/GigaChat-2-Lite": {
"gigachat/GigaChat-2": {
"input_cost_per_token": 0.0,
"litellm_provider": "gigachat",
"max_input_tokens": 128000,
@ -24481,6 +24481,15 @@
"output_cost_per_token": 0.0,
"output_vector_size": 2560
},
"gigachat/GigaEmbeddings-3B-2025-09": {
"input_cost_per_token": 0.0,
"litellm_provider": "gigachat",
"max_input_tokens": 4096,
"max_tokens": 4096,
"mode": "embedding",
"output_cost_per_token": 0.0,
"output_vector_size": 2048
},
"gmi/anthropic/claude-opus-4.5": {
"input_cost_per_token": 5e-06,
"litellm_provider": "gmi",

View file

@ -29,6 +29,8 @@ class LiteLLM_ProxyModelTable(LiteLLMPydanticObjectBase):
@model_validator(mode="before")
@classmethod
def check_potential_json_str(cls, values):
if not isinstance(values, dict):
return values
if isinstance(values.get("litellm_params"), str):
try:
values["litellm_params"] = json.loads(values["litellm_params"])

View file

@ -2,17 +2,22 @@
This module is used to pass through requests to the LLM APIs.
"""
from __future__ import annotations
import asyncio
import contextvars
from collections.abc import AsyncGenerator, Coroutine, Generator
from collections.abc import AsyncGenerator, AsyncIterator, Coroutine, Generator, Iterator
from functools import partial
from typing import TYPE_CHECKING, Any, Final, Optional, cast
from types import TracebackType
from typing import Any, Final, cast
import httpx
from httpx._types import CookieTypes, QueryParamTypes, RequestFiles
from httpx._types import CookieTypes, QueryParamTypes, RequestContent, RequestFiles
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.passthrough.utils import CommonUtils
@ -21,9 +26,222 @@ from litellm.utils import client
base_llm_http_handler = BaseLLMHTTPHandler()
from .utils import BasePassthroughUtils
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig
async def _as_async_generator(iterable: AsyncIterator[bytes]) -> AsyncGenerator[bytes, Any]:
async for chunk in iterable:
yield chunk
def _as_generator(iterable: Iterator[bytes]) -> Generator[bytes, Any, Any]:
yield from iterable
class AsyncPassthroughStreamingResponse(AsyncGenerator[Any, Any]):
def __init__(
self,
response: Coroutine[Any, Any, httpx.Response],
litellm_logging_obj: LiteLLMLoggingObj,
provider_config: BasePassthroughConfig,
) -> None:
self._initialized = False
self._status_code: int = 0
self._headers = httpx.Headers()
self._response_coro = response
self._response: httpx.Response
self._iterator: AsyncGenerator[bytes, Any]
self._litellm_logging_obj = litellm_logging_obj
self._provider_config = provider_config
self._raw_bytes: list[bytes] = [] # mutable-ok: instance buffer for streaming chunks
self._flush_scheduled = False
self._background_tasks: set[asyncio.Task] = set() # mutable-ok: instance set for background task tracking
self._hidden_params: dict[str, object] = {} # mutable-ok: router attaches response headers here in place
@property
def status_code(self) -> int:
if not self._initialized:
raise RuntimeError("AsyncPassthroughStreamingResponse must be awaited before accessing status_code")
return self._status_code
@status_code.setter
def status_code(self, value: int) -> None:
self._status_code = value
@property
def headers(self) -> httpx.Headers:
if not self._initialized:
raise RuntimeError("AsyncPassthroughStreamingResponse must be awaited before accessing headers")
return self._headers
@headers.setter
def headers(self, value: httpx.Headers) -> None:
self._headers = value
def __await__(self) -> Iterator[Any]:
async def _init():
if not self._initialized:
self._response = await self._response_coro
self.headers = self._response.headers
self.status_code = self._response.status_code
self._initialized = True
try:
self._response.raise_for_status()
self._iterator = _as_async_generator(self._response.aiter_bytes())
except Exception: # noqa: BLE001 # Safe catch-all for cleanup logic
try:
await self._response.aread()
except Exception: # noqa: BLE001 S110 # Safe catch-all for cleanup logic
pass
try:
await self._response.aclose()
except Exception: # noqa: BLE001 S110 # Safe catch-all for cleanup logic
pass
raise
return self
return _init().__await__()
def _start_flush(self) -> None:
if self._flush_scheduled or not self._raw_bytes:
return
self._flush_scheduled = True
try:
task: Final = asyncio.create_task(
self._litellm_logging_obj.async_flush_passthrough_collected_chunks(
raw_bytes=self._raw_bytes,
provider_config=self._provider_config,
)
)
# Compliant: Save a strong reference to prevent GC
self._background_tasks.add(task)
# Remove the task from the set when it finishes to avoid memory leaks
task.add_done_callback(self._background_tasks.discard)
except Exception as e: # noqa: BLE001 # Safe catch-all for verbose logging
verbose_logger.exception(
"Failed to schedule passthrough spend-tracking flush; %d buffered chunks dropped: %s",
len(self._raw_bytes),
e,
)
def __aiter__(self) -> AsyncPassthroughStreamingResponse:
return self
def aiter_bytes(self) -> AsyncPassthroughStreamingResponse:
return self
async def __anext__(self) -> bytes:
if not self._initialized:
await self # pyright: ignore[reportGeneralTypeIssues] # structural type check misses __await__
try:
chunk: Final = await anext(self._iterator)
self._raw_bytes.append(chunk)
except Exception: # noqa: BLE001 # Safe catch-all for cleanup logic
self._start_flush()
try:
await self._response.aclose()
except Exception: # noqa: BLE001 S110 # Safe catch-all for cleanup logic
pass
raise
else:
return chunk
async def asend(self, value: bytes) -> bytes:
if not self._initialized:
await self # pyright: ignore[reportGeneralTypeIssues] # structural type check misses __await__
return await self._iterator.asend(value)
async def athrow(
self,
typ: BaseException | type[BaseException],
val: BaseException | object = None,
tb: TracebackType | None = None,
) -> bytes:
if not self._initialized:
await self # pyright: ignore[reportGeneralTypeIssues] # structural type check misses __await__
return await self._iterator.athrow(typ, val, tb) # pyright: ignore[reportCallIssue, reportArgumentType] # matches one of the athrow overloads
async def aclose(self) -> None:
self._start_flush()
try:
if self._initialized:
await self._iterator.aclose()
await self._response.aclose()
except Exception: # noqa: BLE001 S110 # Safe catch-all for cleanup logic
pass
class PassthroughStreamingResponse(Generator[Any, Any, Any]):
def __init__(
self,
response: httpx.Response,
litellm_logging_obj: LiteLLMLoggingObj,
provider_config: BasePassthroughConfig,
) -> None:
self._response = response
self.headers = response.headers
self.status_code = response.status_code
self._litellm_logging_obj = litellm_logging_obj
self._provider_config = provider_config
self._iterator: Generator[bytes, Any, Any] = _as_generator(response.iter_bytes())
self._raw_bytes: list[bytes] = [] # mutable-ok: instance buffer for streaming chunks
self._flush_scheduled = False
def _start_flush(self) -> None:
if self._flush_scheduled or not self._raw_bytes:
return
self._flush_scheduled = True
from litellm.utils import executor
try:
executor.submit(
self._litellm_logging_obj.flush_passthrough_collected_chunks,
raw_bytes=self._raw_bytes,
provider_config=self._provider_config,
)
except Exception as e: # noqa: BLE001 # Safe catch-all for verbose logging
verbose_logger.exception(
"Failed to schedule passthrough spend-tracking flush; %d buffered chunks dropped: %s",
len(self._raw_bytes),
e,
)
def __iter__(self) -> PassthroughStreamingResponse:
return self
def __next__(self) -> bytes:
try:
chunk: Final = next(self._iterator)
self._raw_bytes.append(chunk)
except Exception: # noqa: BLE001 # Safe catch-all for cleanup logic
self._start_flush()
try:
self._response.close()
except Exception: # noqa: BLE001 S110 # Safe catch-all for cleanup logic
pass
raise
else:
return chunk
def send(self, value: bytes) -> bytes:
return self._iterator.send(value)
def throw(
self,
typ: BaseException | type[BaseException],
val: BaseException | object = None,
tb: TracebackType | None = None,
) -> bytes:
return self._iterator.throw(typ, val, tb) # pyright: ignore[reportCallIssue, reportArgumentType] # matches one of the throw overloads
def close(self) -> None:
self._start_flush()
try:
self._response.close()
except Exception: # noqa: BLE001 S110 # Safe catch-all for cleanup logic
pass
@client
@ -37,10 +255,10 @@ async def allm_passthrough_route(
api_key: str | None = None,
request_query_params: dict | None = None,
request_headers: dict | None = None,
content: Any | None = None,
content: RequestContent | None = None,
data: dict | None = None,
files: RequestFiles | None = None,
json: Any | None = None,
json: object | None = None,
params: QueryParamTypes | None = None,
cookies: CookieTypes | None = None,
client: HTTPHandler | AsyncHTTPHandler | None = None,
@ -64,7 +282,7 @@ async def allm_passthrough_route(
from litellm.utils import ProviderConfigManager
provider_config = cast(
Optional["BasePassthroughConfig"], kwargs.get("provider_config")
BasePassthroughConfig | None, kwargs.get("provider_config")
) or ProviderConfigManager.get_provider_passthrough_config(
provider=LlmProviders(custom_llm_provider),
model=model,
@ -132,12 +350,12 @@ async def allm_passthrough_route(
if resolved_custom_llm_provider:
try:
provider_config = cast(
Optional["BasePassthroughConfig"], kwargs.get("provider_config")
BasePassthroughConfig | None, kwargs.get("provider_config")
) or ProviderConfigManager.get_provider_passthrough_config(
provider=LlmProviders(resolved_custom_llm_provider),
model=model,
)
except Exception:
except Exception: # noqa: BLE001 S110
# If we can't get provider config, pass None
pass
@ -162,10 +380,10 @@ def llm_passthrough_route(
api_key: str | None = None,
request_query_params: dict | None = None,
request_headers: dict | None = None,
content: Any | None = None,
content: RequestContent | None = None,
data: dict | None = None,
files: RequestFiles | None = None,
json: Any | None = None,
json: object | None = None,
params: QueryParamTypes | None = None,
cookies: CookieTypes | None = None,
client: HTTPHandler | AsyncHTTPHandler | None = None,
@ -190,7 +408,9 @@ def llm_passthrough_route(
_is_async: Final = bool(kwargs.get("allm_passthrough_route", False))
litellm_logging_obj: Final = cast("LiteLLMLoggingObj", kwargs.get("litellm_logging_obj"))
litellm_logging_obj: Final = cast(
LiteLLMLoggingObj, kwargs.get("litellm_logging_obj")
) # cast-ok: logging obj is constructed upstream; tests inject mocks
model, custom_llm_provider, api_key, api_base = get_llm_provider(
model=model,
@ -235,7 +455,7 @@ def llm_passthrough_route(
)
provider_config: Final = cast(
Optional["BasePassthroughConfig"], kwargs.get("provider_config")
BasePassthroughConfig | None, kwargs.get("provider_config")
) or ProviderConfigManager.get_provider_passthrough_config(
provider=LlmProviders(custom_llm_provider),
model=model,
@ -276,10 +496,13 @@ def llm_passthrough_route(
forward_headers=False,
)
_request_data: dict | None = (
data if isinstance(data, dict) else (json if isinstance(json, dict) else None)
) # rebind-ok: conditional
headers, signed_json_body = provider_config.sign_request(
headers=headers,
litellm_params=litellm_params_dict,
request_data=data if data else json,
request_data=_request_data,
api_base=str(updated_url),
model=model,
)
@ -301,9 +524,12 @@ def llm_passthrough_route(
)
## IS STREAMING REQUEST
_streaming_request_data: dict = (
data if isinstance(data, dict) else (json if isinstance(json, dict) else {})
) # rebind-ok: conditional
is_streaming_request: Final = provider_config.is_streaming_request(
endpoint=endpoint,
request_data=data or json or {},
request_data=_streaming_request_data,
)
# Update logging object with streaming status
@ -334,18 +560,26 @@ def llm_passthrough_route(
else:
# Sync path - client.client.send returns Response directly
response: httpx.Response = client.client.send(request=request, stream=is_streaming_request)
response.raise_for_status()
try:
response.raise_for_status()
except Exception: # noqa: BLE001 # Safe catch-all for cleanup logic
try:
response.read()
except Exception: # noqa: BLE001 S110 # Safe catch-all for cleanup logic
pass
try:
response.close()
except Exception: # noqa: BLE001 S110 # Safe catch-all for cleanup logic
pass
raise
if (
hasattr(response, "iter_bytes") and is_streaming_request
): # yield the chunk, so we can store it in the logging object
return _sync_streaming(response, litellm_logging_obj, provider_config)
if hasattr(response, "iter_bytes") and is_streaming_request:
return PassthroughStreamingResponse(response, litellm_logging_obj, provider_config)
else:
# For non-streaming responses, yield the entire response
return response
except Exception as e:
if provider_config is None:
raise e
# provider_config is guaranteed non-None here due to the earlier guard
assert provider_config is not None
raise base_llm_http_handler._handle_error(
e=e,
provider_config=provider_config,
@ -356,8 +590,8 @@ async def _async_passthrough_request(
client: HTTPHandler | AsyncHTTPHandler,
request: httpx.Request,
is_streaming_request: bool,
litellm_logging_obj: "LiteLLMLoggingObj",
provider_config: "BasePassthroughConfig",
litellm_logging_obj: LiteLLMLoggingObj,
provider_config: BasePassthroughConfig,
) -> httpx.Response | AsyncGenerator[Any, Any]:
"""
Handle async passthrough requests.
@ -369,8 +603,7 @@ async def _async_passthrough_request(
# Check if it's a coroutine and await it
if asyncio.iscoroutine(response_result):
if is_streaming_request:
# Pass the coroutine to _async_streaming which will await it
return _async_streaming(
return await AsyncPassthroughStreamingResponse( # pyright: ignore[reportGeneralTypeIssues] # structural type check misses __await__
response=response_result,
litellm_logging_obj=litellm_logging_obj,
provider_config=provider_config,
@ -383,84 +616,3 @@ async def _async_passthrough_request(
else:
# Fallback for sync-like behavior (shouldn't happen in async path)
raise Exception("Expected coroutine from async client")
def _sync_streaming(
response: httpx.Response,
litellm_logging_obj: "LiteLLMLoggingObj",
provider_config: "BasePassthroughConfig",
):
from litellm.utils import executor
raw_bytes: Final[list[bytes]] = []
flush_scheduled = False
try:
for chunk in response.iter_bytes():
raw_bytes.append(chunk)
yield chunk
finally:
if not flush_scheduled and raw_bytes:
flush_scheduled = True
try:
executor.submit(
litellm_logging_obj.flush_passthrough_collected_chunks,
raw_bytes=raw_bytes,
provider_config=provider_config,
)
except Exception as e:
verbose_logger.exception(
"Failed to schedule passthrough spend-tracking flush "
"in _sync_streaming; %d buffered chunks dropped: %s",
len(raw_bytes),
e,
)
async def _async_streaming(
response: Coroutine[Any, Any, httpx.Response],
litellm_logging_obj: "LiteLLMLoggingObj",
provider_config: "BasePassthroughConfig",
):
iter_response: Final = await response
try:
iter_response.raise_for_status()
except Exception:
try:
await iter_response.aclose()
except Exception:
pass
raise
raw_bytes: Final[list[bytes]] = []
flush_scheduled = False
try:
async for chunk in iter_response.aiter_bytes():
raw_bytes.append(chunk)
yield chunk
except Exception:
try:
await iter_response.aclose()
except Exception:
pass
raise
finally:
# GeneratorExit (raised on client disconnect) is not caught by
# `except Exception`; the finally block ensures partial usage
# still gets flushed for spend tracking. See LIT-2642.
if not flush_scheduled and raw_bytes:
flush_scheduled = True
try:
asyncio.create_task(
litellm_logging_obj.async_flush_passthrough_collected_chunks(
raw_bytes=raw_bytes,
provider_config=provider_config,
)
)
except Exception as e:
verbose_logger.exception(
"Failed to schedule passthrough spend-tracking flush "
"in _async_streaming; %d buffered chunks dropped: %s",
len(raw_bytes),
e,
)

View file

@ -474,6 +474,7 @@ class LiteLLMRoutes(enum.Enum):
"/vllm",
"/mistral",
"/milvus",
"/gigachat",
"/watsonx",
]

View file

@ -1495,6 +1495,35 @@ class ProxyBaseLLMRequestProcessing:
def __init__(self, data: dict):
self.data = data
@staticmethod
def _merge_passthrough_streaming_headers(
response_headers: httpx.Headers | dict | None,
custom_headers: dict,
) -> dict:
"""
Merge upstream passthrough headers with proxy/custom headers.
Proxy/custom headers win on key collisions.
"""
excluded_headers: Final = { # mutable-ok: set of header names to exclude from forwarding
"transfer-encoding",
"content-encoding",
"set-cookie",
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"upgrade",
}
merged_headers: Final = { # mutable-ok: dict comprehension for merged headers forwarded to httpx
key: value for key, value in dict(response_headers or {}).items() if key.lower() not in excluded_headers
}
merged_headers.update(custom_headers)
return merged_headers
@staticmethod
def get_custom_headers(
*,
@ -2389,6 +2418,16 @@ class ProxyBaseLLMRequestProcessing:
)
if route_type == "allm_passthrough_route":
upstream_response_headers: Final = getattr(response, "headers", None)
streaming_headers: Final = (
ProxyBaseLLMRequestProcessing._merge_passthrough_streaming_headers(
response_headers=upstream_response_headers,
custom_headers=custom_headers,
)
if upstream_response_headers is not None
else custom_headers
)
# Check if response is an async generator
if self._is_streaming_response(response):
if asyncio.iscoroutine(response):
@ -2418,11 +2457,11 @@ class ProxyBaseLLMRequestProcessing:
# For passthrough routes, stream directly without error parsing
# since we're dealing with raw binary data (e.g., AWS event streams)
return StreamingResponse(
content=generator,
status_code=status.HTTP_200_OK,
return _UpstreamClosingStreamingResponse(
content=generator, # pyright: ignore[reportArgumentType] # generator-configured StreamingResponse
status_code=getattr(response, "status_code", status.HTTP_200_OK),
media_type=self._passthrough_event_stream_media_type(),
headers=custom_headers,
headers=streaming_headers,
)
else:
_early = await self._handle_non_streaming_allm_passthrough_route(
@ -2437,7 +2476,7 @@ class ProxyBaseLLMRequestProcessing:
return StreamingResponse(
content=response.aiter_bytes(),
status_code=response.status_code,
headers=custom_headers,
headers=streaming_headers,
)
elif route_type == "anthropic_messages":
# Check if response is actually a streaming response (async generator)

View file

@ -19,13 +19,13 @@ import re
import secrets
import traceback
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from contextlib import AbstractAsyncContextManager
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, TypeVar, cast
import fastapi
import yaml
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_proxy_logger
@ -155,6 +155,7 @@ from litellm.types.utils import (
)
if TYPE_CHECKING:
import prisma
from prisma import Prisma
from prisma import models as prisma_models
@ -182,6 +183,14 @@ class _TxTables(Protocol):
litellm_proxymodeltable: TableActions[object]
class _ModelParamsUpdate(TypedDict):
litellm_params: ReadOnly["prisma.Json"]
class _ModelRowWhere(TypedDict):
model_id: ReadOnly[str]
class _ConfigTableActions(Protocol):
"""Config table surface this module needs; the shared repository seam exposes no ``update``."""
@ -273,12 +282,6 @@ def _env_vars_param_value(param: _EnvVarsParam) -> Mapping[str, str] | None:
return param.param_value
def _tx_tables_context(
open_tx: Callable[[], AbstractAsyncContextManager[_TxTables]],
) -> AbstractAsyncContextManager[_TxTables]:
return open_tx()
async def _check_custom_key_allowed(custom_key_value: str | None) -> None:
"""Raise 403 if custom API keys are disabled and a custom key was provided."""
if custom_key_value is None:
@ -4484,27 +4487,29 @@ async def _rotate_master_key(
if models:
decrypted_models: Final = proxy_config.decrypt_model_list_from_db(new_models=models)
verbose_proxy_logger.debug("ABLE TO DECRYPT MODELS - len(decrypted_models): %s", len(decrypted_models))
new_models: Final[list[dict[str, object]]] = []
for model in decrypted_models:
new_model = await _add_model_to_db(
model_params=Deployment(**model),
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
new_encryption_key=new_master_key,
should_create_model_in_db=False,
)
if new_model:
_dumped = dict[str, object](_as_object_dict(new_model.model_dump(exclude_none=True)))
_dumped["litellm_params"] = prisma.Json(_dumped["litellm_params"])
_dumped["model_info"] = prisma.Json(_dumped["model_info"])
new_models.append(_dumped)
verbose_proxy_logger.debug("Resetting proxy model table")
async with _tx_tables_context(prisma_client.db.tx) as tx:
await tx.litellm_proxymodeltable.delete_many()
verbose_proxy_logger.debug("Creating %s models", len(new_models))
await tx.litellm_proxymodeltable.create_many(
data=new_models,
)
reencrypted_models: Final = tuple(
[
reencrypted
for model in decrypted_models
if (
reencrypted := await _add_model_to_db(
model_params=Deployment(**model),
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
new_encryption_key=new_master_key,
should_create_model_in_db=False,
)
)
]
)
verbose_proxy_logger.debug("Re-encrypting litellm_params on %s model rows", len(reencrypted_models))
async with prisma_client.db.tx(timeout=timedelta(minutes=2)) as tx_ctx:
tx: Final[_TxTables] = tx_ctx
for reencrypted_model in reencrypted_models:
await tx.litellm_proxymodeltable.update_many(
data=_ModelParamsUpdate(litellm_params=prisma.Json(reencrypted_model.litellm_params)),
where=_ModelRowWhere(model_id=reencrypted_model.model_id),
)
await publish_config_change(redis_cache=coordination_redis_cache(), object_type="litellm_proxymodeltable")
# 3. process config table
try:

View file

@ -6,6 +6,8 @@ Provider-specific Pass-Through Endpoints
Use litellm with Anthropic SDK, Vertex AI SDK, Cohere SDK, etc.
"""
from __future__ import annotations
import hmac
import json
import os
@ -28,6 +30,7 @@ from litellm.constants import (
)
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
from litellm.proxy._types import *
from litellm.proxy.auth.handle_jwt import JWTHandler
@ -51,6 +54,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
create_websocket_passthrough_route,
websocket_passthrough_request,
)
from litellm.proxy.utils import ProxyLogging as ProxyLoggingType
from litellm.proxy.utils import is_known_model
from litellm.proxy.vector_store_endpoints.utils import (
assert_proxy_admin_for_vector_store_index_management,
@ -70,13 +74,17 @@ from litellm.utils import ProviderConfigManager
from .passthrough_endpoint_router import PassthroughEndpointRouter
if TYPE_CHECKING:
from litellm.proxy.proxy_server import ProxyConfig as _ProxyConfig
from litellm.router import Router
ProxyConfig = _ProxyConfig # rebind-ok: conditional type alias
else:
ProxyConfig = Any # rebind-ok: runtime fallback
vertex_llm_base: Final = VertexBase()
router: Final = APIRouter()
openai_passthrough_router: Final = APIRouter()
default_vertex_config: Final = None
passthrough_endpoint_router: Final = PassthroughEndpointRouter()
@ -495,8 +503,14 @@ async def milvus_proxy_route(
request_body: Final = await get_request_body(request)
# check collectionName
collection_name: Final = cast(str | None, request_body.get("collectionName"))
extra_headers = {}
_raw_collection_name: Final = request_body.get("collectionName")
if _raw_collection_name is not None and not isinstance(_raw_collection_name, str):
raise HTTPException(
status_code=400,
detail=f"collectionName must be a string. Got {type(_raw_collection_name).__name__}",
)
collection_name: str | None = _raw_collection_name # rebind-ok: locally scoped conversion
extra_headers = {} # mutable-ok: dict for extra headers; rebind-ok: reassigned later from credentials
base_target_url: str | None = None
if not collection_name:
raise HTTPException(
@ -1273,7 +1287,7 @@ def _resolve_vertex_model_from_router(
vertex_location: Current vertex location (may be from URL)
Returns:
Tuple of (encoded_endpoint, endpoint, vertex_project, vertex_location)
tuple of (encoded_endpoint, endpoint, vertex_project, vertex_location)
with resolved values from router config
"""
if not llm_router:
@ -1702,7 +1716,7 @@ def get_vertex_ai_allowed_incoming_headers(request: Request) -> dict:
def get_vertex_pass_through_handler(
call_type: Literal["discovery", "aiplatform"],
call_type: Literal["discovery", "aiplatform"], # noqa: UP037
) -> BaseVertexAIPassThroughHandler:
if call_type == "discovery":
return VertexAIDiscoveryPassThroughHandler()
@ -1726,7 +1740,7 @@ def _override_vertex_params_from_router_credentials(
vertex_location: Current vertex location (from URL)
Returns:
Tuple of (vertex_project, vertex_location) with overridden values if applicable
tuple of (vertex_project, vertex_location) with overridden values if applicable
"""
if router_credentials is None:
return vertex_project, vertex_location
@ -1893,12 +1907,12 @@ async def _prepare_vertex_auth_headers(
authenticated them is stripped on the credential-less branch
Returns:
Tuple containing:
tuple containing:
- headers: dict - Authentication headers to use
- base_target_url: Optional[str] - Updated base target URL
- base_target_url: str | None - Updated base target URL
- headers_passed_through: bool - Whether headers were passed through from request
- vertex_project: Optional[str] - Updated vertex project ID
- vertex_location: Optional[str] - Updated vertex location
- vertex_project: str | None - Updated vertex project ID
- vertex_location: str | None - Updated vertex location
"""
vertex_llm_base: Final = VertexBase()
headers_passed_through = False
@ -2546,7 +2560,7 @@ def _vertex_publisher_model_suffix(model: str) -> str:
return f"{VERTEX_PUBLISHER_MODEL_PREFIX}{model.rsplit('/', 1)[-1]}"
def _get_llm_router() -> "Router | None":
def _get_llm_router() -> Router | None:
from litellm.proxy.proxy_server import llm_router
return llm_router
@ -2586,7 +2600,7 @@ def _resolve_vertex_live_credentials(
def _build_vertex_live_setup_model_rewriter(
vertex_project: str | None,
vertex_location: str | None,
llm_router: "Router | None",
llm_router: Router | None,
) -> Callable[[str], str] | None:
"""
Rewrite the ``setup`` frame's model into the full Vertex resource path the Live API requires.
@ -2606,7 +2620,7 @@ def _build_vertex_live_setup_model_rewriter(
return rewrite
def _resolve_alias_to_upstream_model(setup_model: str, llm_router: "Router | None") -> str:
def _resolve_alias_to_upstream_model(setup_model: str, llm_router: Router | None) -> str:
"""
The Live SDK wraps whatever the caller typed as ``models/<name>``, so a gateway alias arrives prefixed
"""
@ -2796,6 +2810,238 @@ def create_generic_websocket_passthrough_endpoint(
)
@router.api_route(
"/gigachat/{endpoint:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"], # mutable-ok: FastAPI route methods
tags=["Gigachat Pass-through", "pass-through"], # mutable-ok: FastAPI route tags
)
async def gigachat_proxy_route(
endpoint: str,
request: Request,
fastapi_response: Response,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> Response:
"""
[Docs](https://docs.litellm.ai/docs/pass_through/gigachat)
"""
from litellm.proxy.proxy_server import (
general_settings,
llm_router,
proxy_config,
proxy_logging_obj,
select_data_generator,
user_api_base,
user_max_tokens,
user_model,
user_request_timeout,
user_temperature,
version,
)
## check for streaming
request_body: Final[dict[str, object]] = await get_request_body(request)
is_router_model = False # rebind-ok: conditionally set to True when model uses router
raw_model: Final = request_body.get("model")
model: Final = raw_model if isinstance(raw_model, str) else None
if model:
is_router_model = is_passthrough_request_using_router_model(
request_body, llm_router
) # rebind-ok: conditionally set to True
elif any(word in endpoint for word in ("completions", "embeddings")):
raise HTTPException(
status_code=400, detail={"error": "Model is required in request body"}
) # mutable-ok: HTTPException detail dict
# If router model, use dedicated router passthrough handler
# This uses the same common processing path as non-router models
if model and is_router_model and llm_router:
return await handle_gigachat_passthrough_router_model(
model=model,
endpoint=endpoint,
request=request,
request_body=request_body,
fastapi_response=fastapi_response,
llm_router=llm_router,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
general_settings=general_settings,
proxy_config=proxy_config,
select_data_generator=select_data_generator,
user_model=user_model,
user_temperature=user_temperature,
user_request_timeout=user_request_timeout,
user_max_tokens=user_max_tokens,
user_api_base=user_api_base,
version=version,
)
verbose_proxy_logger.debug(
"Gigachat passthrough: Using direct Gigachat model '%s' for endpoint '%s'", model, endpoint
)
from litellm.llms.gigachat.authenticator import get_access_token
from litellm.llms.gigachat.utils import GIGACHAT_BASE_URL
base_target_url: Final = get_secret_str("GIGACHAT_API_BASE") or GIGACHAT_BASE_URL
request_path: Final = httpx.URL(endpoint).path
encoded_endpoint: Final = request_path if request_path.startswith("/") else f"/{request_path}"
base_url: Final = httpx.URL(base_target_url)
updated_url: Final = base_url.copy_with(
path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path(base_url, encoded_endpoint)
)
is_streaming_request: Final = await is_streaming_request_fn(request)
endpoint_func: Final = create_pass_through_route(
endpoint=endpoint,
target=str(updated_url),
custom_headers={"Authorization": f"Bearer {get_access_token()}"},
is_streaming_request=is_streaming_request,
)
return await endpoint_func(
request,
fastapi_response,
user_api_key_dict,
)
async def handle_gigachat_passthrough_router_model(
model: str,
endpoint: str,
request: Request,
request_body: dict,
fastapi_response: Response,
llm_router: litellm.Router,
user_api_key_dict: UserAPIKeyAuth,
proxy_logging_obj: ProxyLoggingType,
general_settings: dict,
proxy_config: ProxyConfig,
select_data_generator: Callable,
user_model: str | None,
user_temperature: float | None,
user_request_timeout: float | None,
user_max_tokens: int | None,
user_api_base: str | None,
version: str | None,
) -> Response | StreamingResponse:
"""
Handle Gigachat passthrough for router models (models defined in config.yaml).
Uses the same common processing path as non-router models to ensure
metadata and hooks are properly initialized.
Args:
model: The router model name (e.g., "gigachat/gigachat-2")
endpoint: The Gigachat endpoint path (e.g., "/chat/completions")
request: The FastAPI request object
request_body: The parsed request body
llm_router: The LiteLLM router instance
user_api_key_dict: The user API key authentication dictionary
proxy_logging_obj: Proxy logging
general_settings: Proxy general settings
proxy_config: Proxy config
select_data_generator: Select data generator function
(additional args for common processing)
Returns:
Response or StreamingResponse depending on endpoint type
"""
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
# Detect streaming based on request body
is_streaming: Final = request_body.get("stream", False) # pyright: ignore[reportUnknownVariableType] # request_body is dict[Unknown, Unknown]
data: dict[str, Any] = await _read_request_body(
request=request
) # mutable-ok: mutated in place by proxy pipeline; pyright: ignore[reportExplicitAny] # Any needed for proxy pipeline
if user_api_key_dict is not None:
auth_metadata: Final = {
metadata_key: value
for metadata_key, value in (
("user_api_key_user_id", getattr(user_api_key_dict, "user_id", None)),
("user_api_key_team_id", getattr(user_api_key_dict, "team_id", None)),
("user_api_key_org_id", getattr(user_api_key_dict, "org_id", None)),
("agent_id", getattr(user_api_key_dict, "agent_id", None)),
)
if value is not None
}
existing_metadata: Final = data.get("metadata")
data["metadata"] = {
**(existing_metadata if isinstance(existing_metadata, dict) else {}),
**auth_metadata,
}
verbose_proxy_logger.debug(
"Gigachat router passthrough: model='%s', endpoint='%s', streaming=%s", model, endpoint, is_streaming
)
# Use the common processing path (same as non-router models)
# This ensures all metadata, hooks, and logging are properly initialized
data["model"] = model
data["method"] = request.method
data["endpoint"] = endpoint
data["json"] = request_body
data["custom_llm_provider"] = "gigachat"
# Remove sensitive keys from data
keys: Final = [ # mutable-ok: list of keys to remove from data
"gigachat_auth_url",
"gigachat_access_token",
"gigachat_scope",
"api_base",
"api_key",
]
for key in keys:
data.pop(key, None)
client: Final = get_async_httpx_client(
llm_provider=LlmProviders.GIGACHAT,
params={ # mutable-ok: httpx client params
"timeout": httpx.Timeout(timeout=600.0, connect=5.0),
},
)
data["client"] = client
base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data)
# Use the common passthrough processing to handle metadata and hooks
# This also handles all response formatting (streaming/non-streaming) and exceptions
try:
result = await base_llm_response_processor.base_passthrough_process_llm_request( # rebind-ok: assigned once in try block
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
llm_router=llm_router,
general_settings=general_settings,
proxy_config=proxy_config,
select_data_generator=select_data_generator,
model=model,
user_model=user_model,
user_temperature=user_temperature,
user_request_timeout=user_request_timeout,
user_max_tokens=user_max_tokens,
user_api_base=user_api_base,
version=version,
)
except Exception as e: # noqa: BLE001 # Safe catch-all for handle exception
# Use common exception handling
raise await base_llm_response_processor._handle_llm_api_exception(
e=e,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
)
else:
if isinstance(result, StreamingResponse):
if result.headers.get("Content-Type") is None:
result.headers["Content-Type"] = "text/event-stream; charset=utf-8"
return result
@router.api_route(
"/watsonx/{endpoint:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],

View file

@ -1318,6 +1318,68 @@
],
"default_model_placeholder": "gpt-3.5-turbo"
},
{
"provider": "GIGACHAT",
"provider_display_name": "GigaChat",
"litellm_provider": "gigachat",
"credential_fields": [
{
"key": "api_base",
"label": "API Base",
"placeholder": null,
"tooltip": null,
"required": false,
"field_type": "text",
"options": null,
"default_value": null
},
{
"key": "api_key",
"label": "API Key",
"placeholder": null,
"tooltip": null,
"required": false,
"field_type": "password",
"options": null,
"default_value": null
},
{
"key": "gigachat_scope",
"label": "Scope",
"placeholder": null,
"tooltip": null,
"required": false,
"field_type": "select",
"options": [
"GIGACHAT_API_PERS",
"GIGACHAT_API_B2B",
"GIGACHAT_API_CORP"
],
"default_value": "GIGACHAT_API_PERS"
},
{
"key": "gigachat_auth_url",
"label": "Auth URL",
"placeholder": null,
"tooltip": null,
"required": false,
"field_type": "text",
"options": null,
"default_value": null
},
{
"key": "gigachat_access_token",
"label": "Access token",
"placeholder": null,
"tooltip": "Disable OAuth, provide value to authorization.",
"required": false,
"field_type": "password",
"options": null,
"default_value": null
}
],
"default_model_placeholder": "GigaChat-2"
},
{
"provider": "GITHUB",
"provider_display_name": "Github",

View file

@ -6486,6 +6486,8 @@ class Router:
**kwargs,
)
elif call_type == "allm_passthrough_route":
if client:
kwargs["client"] = client
return await self._ageneric_api_call_with_fallbacks(
original_function=original_function,
passthrough_on_no_deployment=True,

View file

@ -479,6 +479,33 @@ def _last_human_ask_index(
)
def _newest_turn_is_human_ask(
messages: Sequence[Mapping[str, object]] | None,
marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS,
) -> bool:
"""Whether the request's newest turn carries a real human ask, i.e. this is a new ask rather
than an agent loop's continuation traffic.
Anchored on `_last_human_ask_index` so every surface's plumbing reads as a continuation:
chat-completions tool turns are role=tool, Messages-surface tool_result turns flatten to empty
human text, and a hybrid turn carrying an ask alongside a tool_result still counts as an ask.
Compared against the newest non-system message rather than the raw tail, because Claude Code
appends a system-role reminder after the human turn; that trailing plumbing is neither an ask
nor loop traffic and must not turn a fresh ask into a continuation. An unreadable request (no
messages) is treated as a continuation: there is no ask to classify, which is the same reading
`_extract_current_ask_and_system_prompt` gives it downstream.
"""
if not messages:
return False
newest_non_system: Final = next(
(index for index in range(len(messages) - 1, -1, -1) if messages[index].get("role") != "system"),
None,
)
if newest_non_system is None:
return False
return _last_human_ask_index(messages, marker_pairs) == newest_non_system
def _iter_system_scope_texts(
body_system: object,
messages: Sequence[Mapping[str, object]],
@ -2247,14 +2274,18 @@ class ComplexityRouter(CustomLogger):
@property
def _uses_tier_pin(self) -> bool:
return bool(self.config.session_affinity and not self.config.plugins)
"""classification_mode 'user_turn' implies the tier pin machinery: the pin write after each
pinnable classification is what gives a continuation a held decision to replay."""
return bool(
(self.config.session_affinity or self.config.classification_mode == "user_turn") and not self.config.plugins
)
@property
def _uses_deployment_pin(self) -> bool:
"""session_affinity implies the deployment pin: a session frozen onto one model
"""The tier pin implies the deployment pin: a session frozen onto one model
group but load-balanced across its deployments would still go cache-cold, which
is the exact failure both flags exist to prevent."""
return bool((self.config.deployment_affinity or self.config.session_affinity) and not self.config.plugins)
return bool(self.config.deployment_affinity and not self.config.plugins) or self._uses_tier_pin
def _with_session_deployment_affinity(
self, response: PreRoutingHookResponse | None
@ -2282,6 +2313,11 @@ class ComplexityRouter(CustomLogger):
pins the model chosen on the session's first turn and reuses it for every later
turn, skipping classification entirely. Otherwise delegates to `_classify_and_route`.
When `classification_mode` is 'user_turn', the same pin is replayed only on
continuation turns (an agent loop's tool traffic); a new human ask always falls
through to classification, so the session can still move tiers between asks.
With both knobs on, session_affinity's pin-first behavior wins.
Skipped entirely when `plugins` are configured: reusing a stale pin would bypass
the plugin pipeline on every turn after the first, since a pinned model was never
re-checked against a policy plugin whose decision can change between turns (e.g. a
@ -2305,7 +2341,13 @@ class ComplexityRouter(CustomLogger):
session_id: Final = self._get_session_id_from_request_kwargs(request_kwargs) if use_session_affinity else None
cache_key = self._get_session_affinity_cache_key(session_id, request_kwargs) if session_id is not None else None
if cache_key is not None:
# In 'user_turn' mode a held pin is replayed only on continuation turns; a new human
# ask falls through and re-classifies. session_affinity restores pin-first for asks too.
pin_replay_allowed: Final = bool(self.config.session_affinity) or not _newest_turn_is_human_ask(
resolved_messages, self._reminder_markers
)
if cache_key is not None and pin_replay_allowed:
pinned_value: Final = await self.litellm_router_instance.cache.async_get_cache(key=cache_key)
pinned_pin: Final = _parse_session_affinity_pin(pinned_value)
if pinned_pin is not None:
@ -2354,10 +2396,11 @@ class ComplexityRouter(CustomLogger):
kwargs_metadata: Final = request_kwargs.setdefault("metadata", {})
if isinstance(kwargs_metadata, dict):
kwargs_metadata[ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY] = routed_model
replay_cause: Final[RoutingDecisionCause] = (
"session_affinity_pin" if self.config.session_affinity else "user_turn_continuation"
)
cause: RoutingDecisionCause = (
"plan_mode"
if plan_floored
else ("session_affinity_escalation" if escalated else "session_affinity_pin")
"plan_mode" if plan_floored else ("session_affinity_escalation" if escalated else replay_cause)
)
verbose_router_logger.info(
"ComplexityRouter: routing decision cause=%s, routed_model=%s", cause, routed_model

View file

@ -839,6 +839,21 @@ class ComplexityRouterConfig(BaseModel):
description="Minimum cosine similarity for a semantic keyword match",
)
classification_mode: Literal["every_request", "user_turn"] = Field(
default="every_request",
description=(
"When to run the complexity classifier. 'every_request' (the default) classifies every "
"inference request, including the tool-result continuation turns of an agentic loop. "
"'user_turn' classifies only requests whose newest turn is a new human ask and replays "
"the session's held routing decision on continuation turns, which cuts classifier "
"spend and eliminates mid-loop model switches. Continuations with no held decision to "
"replay (no resolvable session_id, expired pin, fresh restart) still classify. Unlike "
"session_affinity, a new human ask always re-classifies, so a session can still move "
"tiers between asks. Suppressed when plugins are configured, for the same reason "
"session_affinity is: a replayed decision would bypass the plugin pipeline."
),
)
# Session affinity: pin the first turn's routed model for the rest of the session
session_affinity: bool = Field(
default=False,

View file

@ -2842,6 +2842,11 @@ RoutingDecisionCause = Literal[
"housekeeping",
"session_affinity_pin",
"session_affinity_escalation",
# classification_mode 'user_turn': the request is an agent loop's continuation turn (no new
# human ask), so the session's held routing decision was replayed and the classifier was never
# called. Distinct from "session_affinity_pin", which reports the session_affinity flag pinning
# every turn including new asks; this cause only appears when session_affinity is off.
"user_turn_continuation",
"default_fallback",
"keyword",
"quality_tier",

View file

@ -8830,6 +8830,12 @@ class ProviderConfigManager:
)
return AzurePassthroughConfig()
elif LlmProviders.GIGACHAT == provider:
from litellm.llms.gigachat.passthrough.transformation import (
GigaChatPassthroughConfig,
)
return GigaChatPassthroughConfig()
elif LlmProviders.WATSONX == provider:
from litellm.llms.watsonx.passthrough.transformation import (
WatsonxPassthroughConfig,

View file

@ -24419,7 +24419,7 @@
"supports_response_schema": true,
"supports_vision": true
},
"gigachat/GigaChat-2-Lite": {
"gigachat/GigaChat-2": {
"input_cost_per_token": 0.0,
"litellm_provider": "gigachat",
"max_input_tokens": 128000,
@ -24481,6 +24481,15 @@
"output_cost_per_token": 0.0,
"output_vector_size": 2560
},
"gigachat/GigaEmbeddings-3B-2025-09": {
"input_cost_per_token": 0.0,
"litellm_provider": "gigachat",
"max_input_tokens": 4096,
"max_tokens": 4096,
"mode": "embedding",
"output_cost_per_token": 0.0,
"output_vector_size": 2048
},
"gmi/anthropic/claude-opus-4.5": {
"input_cost_per_token": 5e-06,
"litellm_provider": "gmi",

View file

@ -180,6 +180,12 @@ class ModelBlockBody(BaseModel):
model_id: str
class ModelBlockResponse(BaseModel):
model_config = ConfigDict(protected_namespaces=())
model_id: str
blocked: bool
class ModelInfoBlockDetail(BaseModel):
id: str | None = None
blocked: bool | None = None
@ -245,11 +251,6 @@ class TestModelRoutes:
def test_block_then_unblock_persists_to_model_info(
self, client: ManagementClient, resources: ResourceManager
) -> None:
"""The blocked flag's persistence is read back from /model/info, not from the
/model/block response: that route currently returns a non-2xx serialization
envelope even though the DB write lands, so the /model/info read-back is the
authoritative persistence contract and keeps this test valid once the
response shape is fixed."""
model_name = f"e2e-mgmt-model-block-{unique_marker()}"
model_id = _create_db_model(client, resources, model_name)
@ -257,27 +258,25 @@ class TestModelRoutes:
f"{model_name!r} already reports blocked in /model/info before /model/block ran"
)
_ = client.proxy.transport.send(
"/model/block",
headers=client.proxy.transport.master,
json=ModelBlockBody(model_id=model_id),
)
_ = _poll(
client.proxy,
lambda: True if _model_blocked_flag(client, model_id) is True else None,
f"/model/info never reported {model_name!r} blocked after /model/block",
)
_ = client.proxy.transport.send(
"/model/unblock",
headers=client.proxy.transport.master,
json=ModelBlockBody(model_id=model_id),
)
_ = _poll(
client.proxy,
lambda: True if _model_blocked_flag(client, model_id) is not True else None,
f"/model/info never cleared blocked for {model_name!r} after /model/unblock",
)
for action, expected in (("block", True), ("unblock", False)):
response = unwrap(
client.proxy.transport.post(
f"/model/{action}",
headers=client.proxy.transport.master,
json=ModelBlockBody(model_id=model_id),
response_type=ModelBlockResponse,
)
)
assert response.model_id == model_id
assert response.blocked is expected
_ = _poll(
client.proxy,
lambda want=expected: True
if _model_blocked_flag(client, model_id) is want
else None,
f"/model/info never reported blocked={expected} for {model_name!r} "
f"after /model/{action}",
)
class TestTagRoutes:

View file

@ -0,0 +1,53 @@
import { test, expect, Page as PlaywrightPage } from "@playwright/test";
import { ADMIN_STORAGE_PATH } from "../../constants";
import { Page } from "../../fixtures/pages";
import { navigateToPage } from "../../helpers/navigation";
const rootPath = (): string => process.env.SERVER_ROOT_PATH ?? "";
async function createScimTokenViaUi(page: PlaywrightPage, alias: string): Promise<string> {
await navigateToPage(page, Page.AdminPanel);
await page.getByRole("tab", { name: "SCIM" }).click();
await expect(page.getByText("SCIM Tenant URL")).toBeVisible();
await expect(page.locator("input[disabled]").first()).toHaveValue(/\/scim\/v2$/);
await page.getByLabel("Token Name").fill(alias);
await page.getByRole("button", { name: "Create SCIM Token" }).click();
await expect(page.getByText(/copy this token now/i)).toBeVisible({ timeout: 15_000 });
const token = await page.locator('input[type="password"]').inputValue();
expect(token, "the one-time token panel shows a usable virtual key").toMatch(/^sk-/);
return token;
}
test.describe("Admin Settings - SCIM", () => {
test.use({ storageState: ADMIN_STORAGE_PATH });
test("Create SCIM Token shows the token once and offers to create another", async ({ page }) => {
await createScimTokenViaUi(page, `e2e-scim-ui-${Date.now()}`);
await page.getByRole("button", { name: "Create Another Token" }).click();
await expect(page.getByRole("button", { name: "Create SCIM Token" })).toBeVisible();
await expect(page.getByText(/copy this token now/i)).toBeHidden();
});
test("a UI-minted SCIM token authorizes the SCIM API", async ({ page, request }) => {
test.skip(!process.env.LITELLM_LICENSE, "LITELLM_LICENSE not set in test env — /scim/v2 is premium-gated");
const token = await createScimTokenViaUi(page, `e2e-scim-api-${Date.now()}`);
const denied = await request.get(`${rootPath()}/scim/v2/Groups`, {
headers: { Authorization: "Bearer sk-not-a-real-key" },
});
expect(denied.status(), "an unknown key must not reach SCIM").toBe(401);
const res = await request.get(`${rootPath()}/scim/v2/Groups`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status(), `SCIM Groups listing failed: ${await res.text()}`).toBe(200);
const body = await res.json();
expect(body.schemas, "SCIM answers with a ListResponse").toContain("urn:ietf:params:scim:api:messages:2.0:ListResponse");
expect(Array.isArray(body.Resources), "SCIM ListResponse carries a Resources array").toBe(true);
});
});

View file

View file

@ -0,0 +1,62 @@
from typing import Final
from unittest.mock import MagicMock
import pytest
import litellm
from litellm.constants import OPENAI_CHAT_COMPLETION_PARAMS
from litellm.endpoints.speech.speech_to_completion_bridge.transformation import (
SpeechToCompletionBridgeTransformationHandler,
)
GEMINI_TTS_MODEL: Final = "gemini-3.1-flash-tts-preview"
def _bridge_request(response_format: str | None) -> dict:
optional_params: Final = (
{"temperature": 0.4} if response_format is None else {"temperature": 0.4, "response_format": response_format}
)
return SpeechToCompletionBridgeTransformationHandler().transform_request(
model=GEMINI_TTS_MODEL,
input="Hello from LiteLLM",
voice="Kore",
optional_params=optional_params,
litellm_params={},
headers={},
litellm_logging_obj=MagicMock(),
custom_llm_provider="gemini",
)
@pytest.mark.parametrize("response_format", ["wav", "mp3", "pcm", None])
def test_gemini_tts_request_keeps_speech_response_format_out_of_chat_params(response_format: str | None) -> None:
request: Final = _bridge_request(response_format)
assert "response_format" not in request
assert request["audio"] == {"voice": "Kore", "format": "pcm16"}
assert request["temperature"] == 0.4
assert request["modalities"] == ["audio"]
gemini_params: Final = litellm.get_optional_params(
model=GEMINI_TTS_MODEL,
custom_llm_provider="gemini",
**{param: value for param, value in request.items() if param in OPENAI_CHAT_COMPLETION_PARAMS},
)
assert gemini_params["speechConfig"] == {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}
assert "responseMimeType" not in gemini_params
def test_non_gemini_request_forwards_speech_response_format_as_audio_format() -> None:
request: Final = SpeechToCompletionBridgeTransformationHandler().transform_request(
model="gpt-4o-audio-preview",
input="Hello from LiteLLM",
voice="alloy",
optional_params={"response_format": "wav"},
litellm_params={},
headers={},
litellm_logging_obj=MagicMock(),
custom_llm_provider="openai",
)
assert "response_format" not in request
assert request["audio"] == {"voice": "alloy", "format": "wav"}

View file

@ -184,3 +184,26 @@ class TestTogetherApiBaseResolvesProvider:
assert provider == "together_ai"
assert api_base == "https://api.together.ai/v1"
class TestGigachatApiBaseResolvesProvider:
"""
Regression for the GigaChat api_base branch: the provider-mapping chain
carried an ``endpoint == "https://gigachat.devices.sberbank.ru/api/v1"``
elif, but the URL was never added to ``openai_compatible_endpoints``, so
the endpoint loop never fired the branch and a caller-supplied GigaChat
api_base raised BadRequestError instead of resolving to ``gigachat``.
"""
def test_gigachat_api_base_resolves_to_gigachat(self, monkeypatch):
monkeypatch.setenv("GIGACHAT_API_KEY", "gigachat-key-from-env")
model, provider, dynamic_api_key, returned_api_base = get_llm_provider(
model="GigaChat-2",
api_base="https://gigachat.devices.sberbank.ru/api/v1",
)
assert provider == "gigachat"
assert dynamic_api_key == "gigachat-key-from-env"
assert returned_api_base == "https://gigachat.devices.sberbank.ru/api/v1"
assert model == "GigaChat-2"

View file

@ -6101,3 +6101,64 @@ def test_response_timing_metrics_survive_deepcopy(logging_obj):
logging_obj.set_response_timing_metrics({"_response_ms": 12.5})
assert copy.deepcopy(logging_obj).response_timing_metrics == {"_response_ms": 12.5}
def test_passthrough_embeddings_result_swapped_for_callbacks():
"""
Regression: for gigachat passthrough /embeddings, normalize_logging_result
produces an EmbeddingResponse, but the result swap only accepted
ModelResponse, so callbacks kept receiving the raw httpx.Response (which
crashes attribute readers like OTEL). The swap must cover
EmbeddingResponse too.
"""
import datetime as dt
from litellm.types.utils import EmbeddingResponse
logging_obj = LitellmLogging(
model="EmbeddingsGigaR",
messages=[],
stream=False,
call_type="allm_passthrough_route",
start_time=time.time(),
litellm_call_id="passthrough-embed-call-id",
function_id="passthrough-embed-fn-id",
)
logging_obj.update_environment_variables(
litellm_params={},
optional_params={},
model="EmbeddingsGigaR",
custom_llm_provider="gigachat",
endpoint="/embeddings",
request_data={"model": "EmbeddingsGigaR", "input": ["hello"]},
input=["hello"],
)
httpx_response = httpx.Response(
200,
json={
"object": "list",
"data": [
{
"object": "embedding",
"embedding": [0.1, 0.2, 0.3],
"index": 0,
"usage": {"prompt_tokens": 5},
}
],
"model": "EmbeddingsGigaR",
},
request=httpx.Request(
"POST", "https://gigachat.devices.sberbank.ru/api/v1/embeddings"
),
)
_, _, swapped_result = logging_obj._success_handler_helper_fn(
result=httpx_response,
start_time=dt.datetime.now(),
end_time=dt.datetime.now(),
cache_hit=False,
)
assert isinstance(swapped_result, EmbeddingResponse)
assert swapped_result.data[0]["embedding"] == [0.1, 0.2, 0.3]

View file

@ -0,0 +1,87 @@
"""
Tests for litellm.llms.gigachat.chat.streaming
"""
from litellm.llms.gigachat.chat.streaming import GigaChatModelResponseIterator
def _parse(chunk: dict) -> dict:
iterator = GigaChatModelResponseIterator(streaming_response=None, sync_stream=True)
return dict(iterator.chunk_parser(chunk=chunk))
class TestChunkParserUsage:
def test_usage_on_stop_chunk(self):
parsed = _parse(
{
"choices": [{"delta": {"content": ""}, "index": 0, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 25, "completion_tokens": 7, "total_tokens": 32},
}
)
assert parsed["finish_reason"] == "stop"
assert parsed["usage"] is not None
assert parsed["usage"]["prompt_tokens"] == 25
assert parsed["usage"]["completion_tokens"] == 7
assert parsed["usage"]["total_tokens"] == 32
def test_usage_on_function_call_chunk(self):
"""Regression: a final chunk ending in function_call still carries usage; it must not be dropped."""
parsed = _parse(
{
"choices": [
{
"delta": {"function_call": {"name": "get_weather", "arguments": {"city": "Moscow"}}},
"index": 0,
"finish_reason": "function_call",
}
],
"usage": {"prompt_tokens": 40, "completion_tokens": 12, "total_tokens": 52},
}
)
assert parsed["finish_reason"] == "tool_calls"
assert parsed["tool_use"] is not None
assert parsed["usage"] is not None
assert parsed["usage"]["prompt_tokens"] == 40
assert parsed["usage"]["completion_tokens"] == 12
assert parsed["usage"]["total_tokens"] == 52
def test_usage_on_length_chunk(self):
parsed = _parse(
{
"choices": [{"delta": {"content": "truncated"}, "index": 0, "finish_reason": "length"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 128, "total_tokens": 138},
}
)
assert parsed["usage"] is not None
assert parsed["usage"]["total_tokens"] == 138
def test_no_usage_on_interim_chunk(self):
parsed = _parse({"choices": [{"delta": {"content": "hello"}, "index": 0, "finish_reason": None}]})
assert parsed["text"] == "hello"
assert parsed["is_finished"] is False
assert parsed["usage"] is None
def test_cache_hit_usage_folds_cached_tokens_back_in(self):
"""GigaChat reports prompt_tokens and total_tokens after subtracting cached tokens
(docs example: prompt_tokens=1, precached_prompt_tokens=37, total_tokens=5), so the
OpenAI-convention usage must add them back and surface them as cached_tokens."""
parsed = _parse(
{
"choices": [{"delta": {"content": ""}, "index": 0, "finish_reason": "stop"}],
"usage": {
"prompt_tokens": 25,
"completion_tokens": 7,
"total_tokens": 32,
"precached_prompt_tokens": 20,
},
}
)
assert parsed["usage"] is not None
assert parsed["usage"]["prompt_tokens"] == 45
assert parsed["usage"]["total_tokens"] == 52
assert parsed["usage"]["prompt_tokens_details"]["cached_tokens"] == 20

View file

@ -0,0 +1,883 @@
"""
Unit tests for GigaChat chat transformation.
Tests GigaChatConfig covering get_complete_url, validate_environment,
get_supported_openai_params, map_openai_params, _convert_tools_to_functions,
_map_tool_choice, _transform_messages, transform_request, transform_response,
get_model_response_iterator, and get_error_class.
"""
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
from litellm.llms.gigachat.chat.transformation import (
GigaChatConfig,
GigaChatError,
is_valid_json,
)
from litellm.types.utils import ModelResponse, Usage
TRANSFORM_MODULE = "litellm.llms.gigachat.chat.transformation"
def _make_httpx_response(
body: dict, status_code: int = 200
) -> httpx.Response:
return httpx.Response(
status_code=status_code,
headers={"content-type": "application/json"},
content=json.dumps(body).encode("utf-8"),
request=httpx.Request(
"POST",
"https://gigachat.devices.sberbank.ru/api/v1/chat/completions",
),
)
# ---------------------------------------------------------------------------
# is_valid_json
# ---------------------------------------------------------------------------
class TestIsValidJson:
def test_valid_json_object(self):
assert is_valid_json('{"key": "value"}') is True
def test_valid_json_array(self):
assert is_valid_json("[1, 2, 3]") is True
def test_valid_json_string(self):
assert is_valid_json('"hello"') is True
def test_invalid_json(self):
assert is_valid_json("{invalid}") is False
def test_empty_string(self):
assert is_valid_json("") is False
# ---------------------------------------------------------------------------
# GigaChatConfig
# ---------------------------------------------------------------------------
class TestGetCompleteUrl:
def setup_method(self):
self.config = GigaChatConfig()
def test_uses_api_base_from_param(self):
url = self.config.get_complete_url(
api_base="https://custom.example.com",
api_key=None,
model="GigaChat",
optional_params={},
litellm_params={},
stream=False,
)
assert url == "https://custom.example.com/chat/completions"
def test_uses_api_base_with_trailing_slash(self):
url = self.config.get_complete_url(
api_base="https://custom.example.com/",
api_key=None,
model="GigaChat",
optional_params={},
litellm_params={},
stream=False,
)
# get_api_base passes the value through without stripping the slash
assert url == "https://custom.example.com//chat/completions"
def test_uses_api_base_from_get_api_base_when_none(self):
url = self.config.get_complete_url(
api_base=None,
api_key=None,
model="GigaChat",
optional_params={},
litellm_params={},
stream=False,
)
assert url.endswith("/chat/completions")
class TestValidateEnvironment:
def setup_method(self):
self.config = GigaChatConfig()
@patch(f"{TRANSFORM_MODULE}.get_access_token", return_value="test-token")
@patch(f"{TRANSFORM_MODULE}.get_secret_str", return_value=None)
def test_sets_auth_headers(self, mock_get_secret, mock_get_token):
headers: dict = {}
result = self.config.validate_environment(
headers=headers,
model="GigaChat",
messages=[{"role": "user", "content": "hi"}],
optional_params={},
litellm_params={},
api_key="creds",
api_base="https://api.example.com",
)
assert result["Authorization"] == "Bearer test-token"
assert result["Content-Type"] == "application/json"
assert result["Accept"] == "application/json"
@patch(f"{TRANSFORM_MODULE}.get_access_token", return_value="token")
@patch(f"{TRANSFORM_MODULE}.get_secret_str", return_value=None)
def test_stores_credentials_and_api_base_for_image_uploads(
self, mock_get_secret, mock_get_token
):
self.config.validate_environment(
headers={},
model="GigaChat",
messages=[],
optional_params={},
litellm_params={},
api_key="my-creds",
api_base="https://my-api.example.com",
)
assert self.config._current_credentials == "my-creds"
assert self.config._current_api_base == "https://my-api.example.com"
@patch(f"{TRANSFORM_MODULE}.get_access_token", return_value="token")
@patch(f"{TRANSFORM_MODULE}.get_secret_str")
def test_falls_back_to_env_for_credentials( # test-quality-ok: mock-echo of internal wiring
self, mock_get_secret, mock_get_token
):
mock_get_secret.return_value = "env-creds"
self.config.validate_environment(
headers={},
model="GigaChat",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
api_base=None,
)
mock_get_secret.assert_any_call("GIGACHAT_CREDENTIALS") # test-quality-ok: mock-echo of internal wiring
class TestGetSupportedOpenAiParams:
def setup_method(self):
self.config = GigaChatConfig()
def test_returns_expected_params(self):
params = self.config.get_supported_openai_params("GigaChat")
expected = [
"stream",
"temperature",
"top_p",
"max_tokens",
"max_completion_tokens",
"stop",
"tools",
"tool_choice",
"functions",
"function_call",
"response_format",
]
assert params == expected
class TestMapOpenAiParams:
def setup_method(self):
self.config = GigaChatConfig()
def test_stream(self):
result = self.config.map_openai_params(
non_default_params={"stream": True},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result["stream"] is True
def test_temperature_zero_maps_to_top_p_zero(self):
result = self.config.map_openai_params(
non_default_params={"temperature": 0},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result["top_p"] == 0
assert "temperature" not in result
def test_temperature_non_zero(self):
result = self.config.map_openai_params(
non_default_params={"temperature": 0.7},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result["temperature"] == 0.7
def test_top_p(self):
result = self.config.map_openai_params(
non_default_params={"top_p": 0.5},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result["top_p"] == 0.5
def test_max_tokens(self):
result = self.config.map_openai_params(
non_default_params={"max_tokens": 100},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result["max_tokens"] == 100
def test_max_completion_tokens(self):
result = self.config.map_openai_params(
non_default_params={"max_completion_tokens": 200},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result["max_tokens"] == 200
def test_stop_is_dropped(self):
result = self.config.map_openai_params(
non_default_params={"stop": ["\n\n"]},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert "stop" not in result
def test_tools_converted_to_functions(self):
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object"},
},
}
]
result = self.config.map_openai_params(
non_default_params={"tools": tools},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert "functions" in result
assert result["functions"] == [
{"name": "get_weather", "description": "Get weather", "parameters": {"type": "object"}}
]
def test_tool_choice_auto(self):
result = self.config.map_openai_params(
non_default_params={"tool_choice": "auto"},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result.get("function_call") == "auto"
def test_tool_choice_none(self):
result = self.config.map_openai_params(
non_default_params={"tool_choice": "none"},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result.get("function_call") == "none"
def test_tool_choice_required(self):
result = self.config.map_openai_params(
non_default_params={"tool_choice": "required"},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result.get("function_call") == "auto"
def test_tool_choice_dict(self):
result = self.config.map_openai_params(
non_default_params={
"tool_choice": {
"type": "function",
"function": {"name": "get_weather"},
}
},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result.get("function_call") == {"name": "get_weather"}
def test_functions(self):
funcs = [{"name": "my_func", "description": "desc", "parameters": {}}]
result = self.config.map_openai_params(
non_default_params={"functions": funcs},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result["functions"] == funcs
def test_function_call(self):
result = self.config.map_openai_params(
non_default_params={"function_call": {"name": "my_func"}},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result["function_call"] == {"name": "my_func"}
def test_response_format_json_schema(self):
response_format = {
"type": "json_schema",
"json_schema": {
"name": "test_schema",
"schema": {"type": "object", "properties": {"name": {"type": "string"}}},
},
}
result = self.config.map_openai_params(
non_default_params={"response_format": response_format},
optional_params={"functions": []},
model="GigaChat",
drop_params=False,
)
# Should add a function for the schema
assert len(result["functions"]) == 1
assert result["functions"][0]["name"] == "test_schema"
assert result["function_call"] == {"name": "test_schema"}
assert result["_structured_output"] is True
class TestConvertToolsToFunctions:
def setup_method(self):
self.config = GigaChatConfig()
def test_converts_function_tools_only(self):
tools = [
{"type": "function", "function": {"name": "a", "description": "d", "parameters": {}}},
{"type": "code_interpreter"}, # should be ignored
]
result = self.config._convert_tools_to_functions(tools)
assert len(result) == 1
assert result[0]["name"] == "a"
def test_empty_tools(self):
assert self.config._convert_tools_to_functions([]) == []
class TestMapToolChoice:
def setup_method(self):
self.config = GigaChatConfig()
def test_none(self):
assert self.config._map_tool_choice("none") == "none"
def test_auto(self):
assert self.config._map_tool_choice("auto") == "auto"
def test_required(self):
assert self.config._map_tool_choice("required") == "auto"
def test_dict_with_function(self):
result = self.config._map_tool_choice(
{"type": "function", "function": {"name": "get_weather"}}
)
assert result == {"name": "get_weather"}
def test_dict_without_name(self):
result = self.config._map_tool_choice(
{"type": "function", "function": {}}
)
assert result is None
def test_unknown_value(self):
assert self.config._map_tool_choice("unknown") is None
class TestTransformMessages:
def setup_method(self):
self.config = GigaChatConfig()
def test_developer_role_to_system(self):
result = self.config._transform_messages(
[{"role": "developer", "content": "be helpful"}]
)
assert result[0]["role"] == "system"
assert result[0]["content"] == "be helpful"
def test_system_message_not_first_becomes_user(self):
result = self.config._transform_messages([
{"role": "user", "content": "hi"},
{"role": "system", "content": "instruction"},
])
assert result[0]["role"] == "user"
assert result[1]["role"] == "user"
assert result[1]["content"] == "instruction"
def test_tool_role_to_function(self):
result = self.config._transform_messages([
{"role": "tool", "content": '{"result": "ok"}'}
])
assert result[0]["role"] == "function"
def test_tool_role_content_wraps_non_json(self):
result = self.config._transform_messages([
{"role": "tool", "content": "plain text"}
])
assert result[0]["role"] == "function"
assert is_valid_json(result[0]["content"])
def test_none_content_becomes_empty_string(self):
result = self.config._transform_messages([
{"role": "user", "content": None}
])
assert result[0]["content"] == ""
def test_name_field_removed(self):
result = self.config._transform_messages([
{"role": "user", "content": "hi", "name": "John"}
])
assert "name" not in result[0]
def test_tool_calls_converted_to_function_call(self):
result = self.config._transform_messages([
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_abc",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "London"}',
},
}
],
}
])
assert "tool_calls" not in result[0]
assert result[0]["function_call"]["name"] == "get_weather"
assert result[0]["function_call"]["arguments"] == {"city": "London"}
def test_tool_calls_with_dict_arguments(self):
result = self.config._transform_messages([
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_xyz",
"type": "function",
"function": {
"name": "search",
"arguments": {"query": "test"},
},
}
],
}
])
assert result[0]["function_call"]["arguments"] == {"query": "test"}
def test_list_content_multimodal(self):
content = [
{"type": "text", "text": "describe this"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/img.jpg"},
},
]
with patch.object(self.config, "_upload_image", return_value="file-123"):
result = self.config._transform_messages([
{"role": "user", "content": content}
])
assert result[0]["content"] == "describe this"
assert result[0]["attachments"] == ["file-123"]
def test_list_content_with_image_url_string(self):
content = [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": "https://example.com/img.jpg"},
]
with patch.object(self.config, "_upload_image", return_value="file-456"):
result = self.config._transform_messages([
{"role": "user", "content": content}
])
assert result[0]["content"] == "look"
assert "file-456" in result[0]["attachments"]
class TestTransformRequest:
def setup_method(self):
self.config = GigaChatConfig()
def test_builds_basic_request(self):
body = self.config.transform_request(
model="gigachat/GigaChat",
messages=[{"role": "user", "content": "hi"}],
optional_params={},
litellm_params={},
headers={},
)
assert body["model"] == "GigaChat"
assert len(body["messages"]) == 1
assert body["messages"][0]["content"] == "hi"
def test_model_prefix_stripped(self):
body = self.config.transform_request(
model="gigachat/GigaChat-Pro",
messages=[{"role": "user", "content": "hello"}],
optional_params={},
litellm_params={},
headers={},
)
assert body["model"] == "GigaChat-Pro"
def test_includes_optional_params(self):
body = self.config.transform_request(
model="gigachat/GigaChat",
messages=[{"role": "user", "content": "hi"}],
optional_params={
"temperature": 0.5,
"max_tokens": 100,
"stream": True,
},
litellm_params={},
headers={},
)
assert body["temperature"] == 0.5
assert body["max_tokens"] == 100
assert body["stream"] is True
def test_includes_functions(self):
body = self.config.transform_request(
model="gigachat/GigaChat",
messages=[{"role": "user", "content": "hi"}],
optional_params={
"functions": [{"name": "my_func"}],
"function_call": {"name": "my_func"},
},
litellm_params={},
headers={},
)
assert body["functions"] == [{"name": "my_func"}]
assert body["function_call"] == {"name": "my_func"}
def test_skips_unsupported_params(self):
body = self.config.transform_request(
model="gigachat/GigaChat",
messages=[{"role": "user", "content": "hi"}],
optional_params={"n": 2, "user": "abc"},
litellm_params={},
headers={},
)
assert "n" not in body
assert "user" not in body
class TestTransformResponse:
def setup_method(self):
self.config = GigaChatConfig()
def test_basic_response(self):
raw = _make_httpx_response({
"id": "chatcmpl-123",
"created": 1700000000,
"model": "GigaChat",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
})
model_response = ModelResponse()
result = self.config.transform_response(
model="gigachat/GigaChat",
raw_response=raw,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=None,
)
assert result.choices[0].message.content == "Hello!"
assert result.choices[0].finish_reason == "stop"
assert result.usage.prompt_tokens == 5
assert result.usage.total_tokens == 8
def test_function_call_into_tool_calls(self):
raw = _make_httpx_response({
"id": "chatcmpl-456",
"created": 1700000000,
"model": "GigaChat",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": None,
"function_call": {
"name": "get_weather",
"arguments": {"city": "Moscow"},
},
},
"finish_reason": "function_call",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
})
model_response = ModelResponse()
result = self.config.transform_response(
model="gigachat/GigaChat",
raw_response=raw,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=None,
)
assert result.choices[0].finish_reason == "tool_calls"
tool_calls = result.choices[0].message.tool_calls
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0].function.name == "get_weather"
assert '{"city": "Moscow"}' in tool_calls[0].function.arguments
def test_function_call_structured_output(self):
raw = _make_httpx_response({
"id": "chatcmpl-789",
"created": 1700000000,
"model": "GigaChat",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": None,
"function_call": {
"name": "test_schema",
"arguments": {"name": "John"},
},
},
"finish_reason": "function_call",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
})
model_response = ModelResponse()
result = self.config.transform_response(
model="gigachat/GigaChat",
raw_response=raw,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
messages=[],
optional_params={"_structured_output": True},
litellm_params={},
encoding=None,
)
# Structured output: function_call -> content
assert result.choices[0].finish_reason == "stop"
assert result.choices[0].message.content is not None
assert '"name": "John"' in result.choices[0].message.content
def test_function_call_string_arguments(self):
raw = _make_httpx_response({
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"function_call": {
"name": "get_weather",
"arguments": '{"city": "Moscow"}',
},
},
"finish_reason": "function_call",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
})
model_response = ModelResponse()
result = self.config.transform_response(
model="gigachat/GigaChat",
raw_response=raw,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=None,
)
tc = result.choices[0].message.tool_calls[0]
assert '{"city": "Moscow"}' in tc.function.arguments
def test_cleans_up_gigachat_specific_fields(self):
raw = _make_httpx_response({
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "done",
"functions_state_id": "some-state",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
})
model_response = ModelResponse()
result = self.config.transform_response(
model="gigachat/GigaChat",
raw_response=raw,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=None,
)
# functions_state_id should have been removed from the message data
assert result.choices[0].message.content == "done"
def test_raises_on_invalid_json(self):
raw = httpx.Response(
status_code=500,
headers={"content-type": "text/plain"},
content=b"not json",
request=httpx.Request("POST", "https://example.com"),
)
model_response = ModelResponse()
with pytest.raises(GigaChatError) as exc_info:
self.config.transform_response(
model="gigachat/GigaChat",
raw_response=raw,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=None,
)
assert "Invalid JSON response" in str(exc_info.value.message)
def test_empty_choices(self):
raw = _make_httpx_response({
"choices": [],
"usage": {},
})
model_response = ModelResponse()
result = self.config.transform_response(
model="gigachat/GigaChat",
raw_response=raw,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=None,
)
assert result.choices == []
def test_function_call_with_non_dict_arguments(self):
raw = _make_httpx_response({
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"function_call": {
"name": "say_hello",
"arguments": "hello",
},
},
"finish_reason": "function_call",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
})
model_response = ModelResponse()
result = self.config.transform_response(
model="gigachat/GigaChat",
raw_response=raw,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=None,
)
tc = result.choices[0].message.tool_calls[0]
assert tc.function.arguments == "hello"
class TestGetModelResponseIterator:
def setup_method(self):
self.config = GigaChatConfig()
def test_returns_gigachat_iterator_sync(self):
from litellm.llms.gigachat.chat.streaming import (
GigaChatModelResponseIterator,
)
result = self.config.get_model_response_iterator(
streaming_response=iter(["data"]),
sync_stream=True,
json_mode=False,
)
assert isinstance(result, GigaChatModelResponseIterator)
class TestGetErrorClass:
def setup_method(self):
self.config = GigaChatConfig()
def test_returns_gigachat_error(self):
error = self.config.get_error_class(
error_message="something went wrong",
status_code=400,
headers={"x-request-id": "abc"},
)
assert isinstance(error, GigaChatError)
assert error.status_code == 400
assert error.message == "something went wrong"
assert error.headers == {"x-request-id": "abc"}
class TestUploadImage:
def setup_method(self):
self.config = GigaChatConfig()
@patch(f"{TRANSFORM_MODULE}.upload_file_sync", return_value="file-uploaded")
def test_upload_image_success(self, mock_upload):
self.config._current_credentials = "creds"
self.config._current_api_base = "https://api.example.com"
result = self.config._upload_image("https://example.com/img.jpg")
assert result == "file-uploaded"
mock_upload.assert_called_once_with(
image_url="https://example.com/img.jpg",
credentials="creds",
api_base="https://api.example.com",
)
@patch(f"{TRANSFORM_MODULE}.upload_file_sync", side_effect=Exception("fail"))
def test_upload_image_failure_returns_none(self, mock_upload):
result = self.config._upload_image("https://example.com/img.jpg")
assert result is None

View file

@ -0,0 +1,372 @@
"""
Unit tests for GigaChat embedding transformation.
Tests GigaChatEmbeddingConfig covering get_config, get_supported_openai_params,
map_openai_params, _get_openai_compatible_provider_info, get_complete_url,
transform_embedding_request, transform_embedding_response, validate_environment,
and get_error_class.
"""
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
from litellm import LlmProviders
from litellm.llms.gigachat.embedding.transformation import (
GigaChatEmbeddingConfig,
GigaChatEmbeddingError,
)
from litellm.types.utils import EmbeddingResponse
TRANSFORM_MODULE = "litellm.llms.gigachat.embedding.transformation"
def _make_httpx_response(body: dict, status_code: int = 200) -> httpx.Response:
return httpx.Response(
status_code=status_code,
headers={"content-type": "application/json"},
content=json.dumps(body).encode("utf-8"),
request=httpx.Request("POST", "https://gigachat.devices.sberbank.ru/api/v1/embeddings"),
)
# ---------------------------------------------------------------------------
# GigaChatEmbeddingConfig
# ---------------------------------------------------------------------------
class TestGetConfig:
def setup_method(self):
self.config = GigaChatEmbeddingConfig()
def test_contains_only_abc_impl(self):
"""get_config returns ABC internal data due to inheritance."""
result = self.config.get_config()
# The only key should be _abc_impl from ABC base class
assert set(result.keys()) == {"_abc_impl"}
class TestGetSupportedOpenAiParams:
def setup_method(self):
self.config = GigaChatEmbeddingConfig()
def test_returns_empty_list(self):
params = self.config.get_supported_openai_params("GigaChat")
assert params == []
class TestMapOpenAiParams:
def setup_method(self):
self.config = GigaChatEmbeddingConfig()
def test_returns_optional_params_unchanged(self):
result = self.config.map_openai_params(
non_default_params={"model": "test"},
optional_params={"temperature": 0.5},
model="GigaChat",
drop_params=False,
)
assert result == {"temperature": 0.5}
def test_returns_empty_dict_when_no_optional_params(self):
result = self.config.map_openai_params(
non_default_params={},
optional_params={},
model="GigaChat",
drop_params=False,
)
assert result == {}
class TestGetOpenaiCompatibleProviderInfo:
def setup_method(self):
self.config = GigaChatEmbeddingConfig()
def test_returns_gigachat_provider(self):
provider, api_base, api_key = self.config._get_openai_compatible_provider_info(
api_base="https://api.example.com", api_key="test-key"
)
assert provider == LlmProviders.GIGACHAT.value
assert api_base == "https://api.example.com"
assert api_key == "test-key"
def test_resolves_api_base_when_none(self, monkeypatch):
monkeypatch.delenv("GIGACHAT_API_BASE", raising=False)
provider, api_base, api_key = self.config._get_openai_compatible_provider_info(
api_base=None, api_key="key"
)
assert api_base is not None
assert api_base.endswith("/api/v1")
def test_returns_none_api_key(self):
_, _, api_key = self.config._get_openai_compatible_provider_info(
api_base="https://example.com", api_key=None
)
assert api_key is None
class TestGetCompleteUrl:
def setup_method(self):
self.config = GigaChatEmbeddingConfig()
def test_default_url(self):
url = self.config.get_complete_url(
api_base=None, api_key=None, model="GigaChat",
optional_params={}, litellm_params={},
)
assert url.endswith("/embeddings")
def test_custom_api_base(self):
url = self.config.get_complete_url(
api_base="https://custom.example.com", api_key=None, model="GigaChat",
optional_params={}, litellm_params={},
)
assert url == "https://custom.example.com/embeddings"
def test_trailing_slash_api_base(self):
url = self.config.get_complete_url(
api_base="https://custom.example.com/", api_key=None, model="GigaChat",
optional_params={}, litellm_params={},
)
# get_api_base doesn't strip slash, so we get double slash
assert url == "https://custom.example.com//embeddings"
class TestTransformEmbeddingRequest:
def setup_method(self):
self.config = GigaChatEmbeddingConfig()
def test_string_input(self):
result = self.config.transform_embedding_request(
model="gigachat/Embeddings",
input="hello world",
optional_params={},
headers={},
)
assert result == {"model": "Embeddings", "input": ["hello world"]}
def test_list_input(self):
result = self.config.transform_embedding_request(
model="gigachat/Embeddings",
input=["text1", "text2"],
optional_params={},
headers={},
)
assert result == {"model": "Embeddings", "input": ["text1", "text2"]}
def test_strips_gigachat_prefix(self):
result = self.config.transform_embedding_request(
model="gigachat/GigaChat-Pro",
input="test",
optional_params={},
headers={},
)
assert result["model"] == "GigaChat-Pro"
def test_model_without_prefix(self):
result = self.config.transform_embedding_request(
model="Embeddings",
input="test",
optional_params={},
headers={},
)
assert result["model"] == "Embeddings"
class TestTransformEmbeddingResponse:
def setup_method(self):
self.config = GigaChatEmbeddingConfig()
self.logging_obj = MagicMock()
def _make_gigachat_response(self, data: list[dict]) -> httpx.Response:
return _make_httpx_response({
"object": "list",
"data": data,
"model": "Embeddings",
})
def test_basic_response(self):
raw = self._make_gigachat_response([
{
"object": "embedding",
"embedding": [0.1, 0.2, 0.3],
"index": 0,
}
])
model_response = EmbeddingResponse()
result = self.config.transform_embedding_response(
model="gigachat/Embeddings",
raw_response=raw,
model_response=model_response,
logging_obj=self.logging_obj,
api_key="test-key",
request_data={"input": ["text"]},
optional_params={},
litellm_params={},
)
assert result.object == "list"
assert len(result.data) == 1
assert result.data[0]["embedding"] == [0.1, 0.2, 0.3]
assert result.data[0]["index"] == 0
assert result.usage.prompt_tokens == 0
assert result.usage.total_tokens == 0
def test_aggregates_per_embedding_usage(self):
raw = self._make_gigachat_response([
{
"object": "embedding",
"embedding": [0.1, 0.2],
"index": 0,
"usage": {"prompt_tokens": 5},
},
{
"object": "embedding",
"embedding": [0.3, 0.4],
"index": 1,
"usage": {"prompt_tokens": 7},
},
])
model_response = EmbeddingResponse()
result = self.config.transform_embedding_response(
model="gigachat/Embeddings",
raw_response=raw,
model_response=model_response,
logging_obj=self.logging_obj,
api_key="test-key",
request_data={"input": ["a", "b"]},
optional_params={},
litellm_params={},
)
# Total should be sum of per-embedding prompt_tokens
assert result.usage.prompt_tokens == 12
assert result.usage.total_tokens == 12
# Usage should be removed from individual embedding data
assert "usage" not in result.data[0]
assert "usage" not in result.data[1]
def test_usage_removed_from_individual_embeddings(self):
raw = self._make_gigachat_response([
{
"object": "embedding",
"embedding": [0.5],
"index": 0,
"usage": {"prompt_tokens": 3},
}
])
model_response = EmbeddingResponse()
result = self.config.transform_embedding_response(
model="gigachat/Embeddings",
raw_response=raw,
model_response=model_response,
logging_obj=self.logging_obj,
api_key="key",
request_data={"input": ["x"]},
optional_params={},
litellm_params={},
)
# usage should NOT be in the final EmbeddingResponse data items
for emb in result.data:
assert "usage" not in emb
def test_passes_model_from_response(self):
raw = self._make_gigachat_response([
{"object": "embedding", "embedding": [0.1], "index": 0},
])
model_response = EmbeddingResponse()
result = self.config.transform_embedding_response(
model="gigachat/Embeddings",
raw_response=raw,
model_response=model_response,
logging_obj=self.logging_obj,
api_key="key",
request_data={"input": ["x"]},
optional_params={},
litellm_params={},
)
assert result.model == "Embeddings"
def test_calls_logging_post_call(self):
raw = self._make_gigachat_response([
{"object": "embedding", "embedding": [0.1], "index": 0},
])
model_response = EmbeddingResponse()
self.config.transform_embedding_response(
model="gigachat/Embeddings",
raw_response=raw,
model_response=model_response,
logging_obj=self.logging_obj,
api_key="test-api-key",
request_data={"input": ["hello"]},
optional_params={},
litellm_params={},
)
self.logging_obj.post_call.assert_called_once()
args = self.logging_obj.post_call.call_args.kwargs
assert args["api_key"] == "test-api-key"
assert args["input"] == ["hello"]
class TestValidateEnvironment:
def setup_method(self):
self.config = GigaChatEmbeddingConfig()
@patch(f"{TRANSFORM_MODULE}.get_access_token", return_value="test-token")
def test_sets_oauth_headers(self, mock_get_token):
headers = self.config.validate_environment(
headers={},
model="GigaChat",
messages=[],
optional_params={},
litellm_params={},
api_key="creds",
api_base="https://api.example.com",
)
assert headers["Authorization"] == "Bearer test-token"
assert headers["Content-Type"] == "application/json"
mock_get_token.assert_called_once_with(credentials="creds", litellm_params={})
@patch(f"{TRANSFORM_MODULE}.get_access_token", return_value="token")
def test_merges_custom_headers(self, mock_get_token):
headers = self.config.validate_environment(
headers={"X-Custom": "value"},
model="GigaChat",
messages=[],
optional_params={},
litellm_params={},
api_key="creds",
api_base="https://api.example.com",
)
assert headers["Authorization"] == "Bearer token"
assert headers["Content-Type"] == "application/json"
assert headers["X-Custom"] == "value"
@patch(f"{TRANSFORM_MODULE}.get_access_token", return_value="token")
def test_custom_header_overwrites_default(self, mock_get_token):
headers = self.config.validate_environment(
headers={"Authorization": "Bearer custom"},
model="GigaChat",
messages=[],
optional_params={},
litellm_params={},
api_key="creds",
api_base="https://api.example.com",
)
# Merge: default headers first, then custom headers on top
assert headers["Authorization"] == "Bearer custom"
class TestGetErrorClass:
def setup_method(self):
self.config = GigaChatEmbeddingConfig()
def test_returns_gigachat_embedding_error(self):
error = self.config.get_error_class(
error_message="embedding failed",
status_code=400,
headers={"x-request-id": "abc"},
)
assert isinstance(error, GigaChatEmbeddingError)
assert error.status_code == 400
assert error.message == "embedding failed"

View file

@ -0,0 +1,607 @@
"""
Unit tests for GigaChatPassthroughConfig transformation.
Tests the GigaChat-specific passthrough configuration including URL construction,
streaming detection, authentication handling, and logging response transformations.
"""
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
from litellm.llms.gigachat.passthrough.transformation import GigaChatPassthroughConfig
from litellm.types.utils import EmbeddingResponse, ModelResponse
def _gigachat_chat_completion_body():
return {
"id": "chatcmpl-test123",
"object": "chat.completion",
"created": 1700000000,
"model": "GigaChat",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello from GigaChat",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 5,
"completion_tokens": 3,
"total_tokens": 8,
},
}
def _gigachat_embedding_body():
return {
"object": "list",
"data": [
{
"object": "embedding",
"embedding": [0.1, 0.2, 0.3],
"index": 0,
"usage": {"prompt_tokens": 4},
}
],
"model": "Embeddings",
}
def _make_httpx_response(body: dict) -> httpx.Response:
return httpx.Response(
status_code=200,
headers={"content-type": "application/json"},
content=json.dumps(body).encode("utf-8"),
request=httpx.Request(
"POST", "https://gigachat.devices.sberbank.ru/api/v1/chat/completions"
),
)
class TestGigaChatPassthroughConfig:
"""Tests for GigaChatPassthroughConfig class."""
def test_is_streaming_request_true(self):
"""Test streaming is detected when stream=True."""
config = GigaChatPassthroughConfig()
assert (
config.is_streaming_request("chat/completions", {"stream": True}) is True
)
def test_is_streaming_request_false(self):
"""Test streaming is not detected when stream=False."""
config = GigaChatPassthroughConfig()
assert (
config.is_streaming_request("chat/completions", {"stream": False})
is False
)
def test_is_streaming_request_missing_stream_key(self):
"""Test streaming defaults to False when stream key is missing."""
config = GigaChatPassthroughConfig()
assert (
config.is_streaming_request("chat/completions", {"model": "GigaChat"})
is False
)
def test_get_complete_url_with_api_base(self):
"""Test URL construction with explicit api_base."""
config = GigaChatPassthroughConfig()
api_base = "https://custom.gigachat.ru/api/v1"
endpoint = "chat/completions"
complete_url, base_target_url = config.get_complete_url(
api_base=api_base,
api_key=None,
model="GigaChat",
endpoint=endpoint,
request_query_params=None,
litellm_params={},
)
assert isinstance(complete_url, httpx.URL)
assert str(complete_url) == f"{api_base}/{endpoint}"
assert base_target_url == api_base
def test_get_complete_url_with_leading_slash_endpoint(self):
"""Test URL construction with endpoint having leading slash."""
config = GigaChatPassthroughConfig()
api_base = "https://custom.gigachat.ru/api/v1"
endpoint = "/chat/completions"
complete_url, base_target_url = config.get_complete_url(
api_base=api_base,
api_key=None,
model="GigaChat",
endpoint=endpoint,
request_query_params=None,
litellm_params={},
)
assert str(complete_url) == "https://custom.gigachat.ru/api/v1/chat/completions"
assert base_target_url == api_base
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.llms.gigachat.passthrough.transformation.get_secret_str"
)
def test_get_complete_url_with_env_api_base(self, mock_get_secret):
"""Test URL construction with api_base from environment."""
config = GigaChatPassthroughConfig()
env_api_base = "https://env.gigachat.ru/api/v1"
mock_get_secret.return_value = env_api_base
complete_url, base_target_url = config.get_complete_url(
api_base=None,
api_key=None,
model="GigaChat",
endpoint="embeddings",
request_query_params=None,
litellm_params={},
)
assert isinstance(complete_url, httpx.URL)
assert str(complete_url).startswith(env_api_base)
assert base_target_url == env_api_base
mock_get_secret.assert_called_once_with("GIGACHAT_API_BASE")
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.llms.gigachat.passthrough.transformation.get_secret_str"
)
def test_get_complete_url_fallback_to_default(self, mock_get_secret):
"""Test URL construction falls back to default GIGACHAT_BASE_URL."""
config = GigaChatPassthroughConfig()
mock_get_secret.return_value = None
complete_url, base_target_url = config.get_complete_url(
api_base=None,
api_key=None,
model="GigaChat",
endpoint="models",
request_query_params=None,
litellm_params={},
)
assert isinstance(complete_url, httpx.URL)
assert "gigachat.devices.sberbank.ru" in str(complete_url)
assert base_target_url == "https://gigachat.devices.sberbank.ru/api/v1"
def test_get_complete_url_no_api_base_raises(self):
"""Test that exception is raised when no api_base can be resolved."""
config = GigaChatPassthroughConfig()
with patch(
"litellm.llms.gigachat.passthrough.transformation.get_secret_str", # test-quality-ok: patching litellm internal for unit test isolation
return_value=None,
):
with patch(
"litellm.llms.gigachat.passthrough.transformation.GIGACHAT_BASE_URL", # test-quality-ok: patching litellm internal for unit test isolation
None,
):
with pytest.raises(Exception, match="GigaChat api base not found"):
config.get_complete_url(
api_base=None,
api_key=None,
model="GigaChat",
endpoint="chat/completions",
request_query_params=None,
litellm_params={},
)
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.llms.gigachat.passthrough.transformation.get_access_token"
)
def test_validate_environment(self, mock_get_access_token):
"""Test headers are set correctly with OAuth token."""
config = GigaChatPassthroughConfig()
mock_get_access_token.return_value = "test-token-123"
headers = config.validate_environment(
headers={},
model="GigaChat",
messages=[{"role": "user", "content": "hi"}],
optional_params={},
litellm_params={},
api_key="test-credentials",
api_base="https://custom.gigachat.ru",
)
assert headers["Authorization"] == "Bearer test-token-123"
assert headers["Content-Type"] == "application/json"
assert headers["Accept"] == "application/json"
mock_get_access_token.assert_called_once_with(
credentials="test-credentials",
litellm_params={},
)
def test_logging_non_streaming_response_chat_completions(self):
"""Test chat completions endpoint returns ModelResponse."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
result = config.logging_non_streaming_response(
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
httpx_response=_make_httpx_response(_gigachat_chat_completion_body()),
request_data={
"model": "gigachat/GigaChat",
"messages": [{"role": "user", "content": "hi"}],
},
logging_obj=logging_obj,
endpoint="chat/completions",
)
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "Hello from GigaChat"
assert result.usage.prompt_tokens == 5
assert result.usage.completion_tokens == 3
assert result.usage.total_tokens == 8
def test_logging_non_streaming_response_embeddings(self):
"""Test embeddings endpoint returns EmbeddingResponse."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
result = config.logging_non_streaming_response(
model="gigachat/Embeddings",
custom_llm_provider="gigachat",
httpx_response=_make_httpx_response(_gigachat_embedding_body()),
request_data={"input": ["hello"], "model": "gigachat/Embeddings"},
logging_obj=logging_obj,
endpoint="embeddings",
)
assert isinstance(result, EmbeddingResponse)
assert len(result.data) == 1
assert result.data[0]["embedding"] == [0.1, 0.2, 0.3]
def test_logging_non_streaming_response_unknown_endpoint_returns_none(self):
"""Test unknown endpoint returns None."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
result = config.logging_non_streaming_response(
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
httpx_response=_make_httpx_response(_gigachat_chat_completion_body()),
request_data={},
logging_obj=logging_obj,
endpoint="images/generations",
)
assert result is None
def test_handle_logging_collected_chunks_with_string_chunks(self):
"""Test converting string chunks to model response."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
chunks = [
'{"choices": [{"delta": {"content": "Hello"}, "index": 0}]}',
'{"choices": [{"delta": {"content": " world"}, "index": 0}]}',
'{"choices": [{"delta": {}, "finish_reason": "stop", "index": 0}], "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}}',
]
result = config.handle_logging_collected_chunks(
all_chunks=chunks,
litellm_logging_obj=logging_obj,
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
endpoint="chat/completions",
)
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "Hello world"
def test_handle_logging_collected_chunks_with_bytes_chunks(self):
"""Test converting string chunks to model response (bytes pre-decoded upstream)."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
chunks = [
'{"choices": [{"delta": {"content": "Hi"}, "index": 0}]}',
'{"choices": [{"delta": {}, "finish_reason": "stop", "index": 0}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}',
]
result = config.handle_logging_collected_chunks(
all_chunks=chunks,
litellm_logging_obj=logging_obj,
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
endpoint="chat/completions",
)
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "Hi"
def test_handle_logging_collected_chunks_with_done_and_empty(self):
"""Test that [DONE] and empty chunks are skipped."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
chunks = [
"",
"[DONE]",
'{"choices": [{"delta": {"content": "test"}, "index": 0}]}',
'{"choices": [{"delta": {}, "finish_reason": "stop", "index": 0}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}',
]
result = config.handle_logging_collected_chunks(
all_chunks=chunks,
litellm_logging_obj=logging_obj,
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
endpoint="chat/completions",
)
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "test"
def test_handle_logging_collected_chunks_with_dict_chunks(self):
"""Test converting string-serialized dict chunks (dicts pre-serialized upstream)."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
chunks = [
'{"choices": [{"delta": {"content": "direct"}, "index": 0}]}',
json.dumps(
{
"choices": [
{
"delta": {},
"finish_reason": "stop",
"index": 0,
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
),
]
result = config.handle_logging_collected_chunks(
all_chunks=chunks,
litellm_logging_obj=logging_obj,
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
endpoint="chat/completions",
)
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "direct"
def test_handle_logging_collected_chunks_empty_list_returns_none(self):
"""Test empty chunks list returns None."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
result = config.handle_logging_collected_chunks(
all_chunks=[],
litellm_logging_obj=logging_obj,
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
endpoint="chat/completions",
)
assert result is None
def test_handle_logging_collected_chunks_invalid_json_skipped(self):
"""Test invalid JSON chunks are skipped gracefully."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
chunks = [
"not-valid-json",
'{"choices": [{"delta": {"content": "valid"}, "index": 0}]}',
'{"choices": [{"delta": {}, "finish_reason": "stop", "index": 0}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}',
]
result = config.handle_logging_collected_chunks(
all_chunks=chunks,
litellm_logging_obj=logging_obj,
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
endpoint="chat/completions",
)
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "valid"
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.llms.gigachat.passthrough.transformation.get_secret_str"
)
def test_get_api_base_with_explicit_value(self, mock_get_secret):
"""Test get_api_base returns explicit value when provided."""
explicit_base = "https://custom.gigachat.ru/api/v1"
result = GigaChatPassthroughConfig.get_api_base(api_base=explicit_base)
assert result == explicit_base
mock_get_secret.assert_not_called()
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.llms.gigachat.passthrough.transformation.get_secret_str"
)
def test_get_api_base_from_environment(self, mock_get_secret):
"""Test get_api_base retrieves from environment when not provided."""
env_base = "https://env.gigachat.ru/api/v1"
mock_get_secret.return_value = env_base
result = GigaChatPassthroughConfig.get_api_base(api_base=None)
assert result == env_base
mock_get_secret.assert_called_once_with("GIGACHAT_API_BASE")
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.llms.gigachat.passthrough.transformation.get_secret_str"
)
def test_get_api_base_fallback_to_default(self, mock_get_secret):
"""Test get_api_base falls back to GIGACHAT_BASE_URL."""
mock_get_secret.return_value = None
result = GigaChatPassthroughConfig.get_api_base(api_base=None)
assert result == "https://gigachat.devices.sberbank.ru/api/v1"
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.llms.gigachat.passthrough.transformation.get_secret_str"
)
def test_get_api_key_with_explicit_value(self, mock_get_secret):
"""Test get_api_key returns explicit value when provided."""
explicit_key = "test-api-key"
result = GigaChatPassthroughConfig.get_api_key(api_key=explicit_key)
assert result == explicit_key
mock_get_secret.assert_not_called()
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.llms.gigachat.passthrough.transformation.get_secret_str"
)
def test_get_api_key_from_environment(self, mock_get_secret):
"""Test get_api_key retrieves from environment when not provided."""
env_key = "env-api-key"
mock_get_secret.return_value = env_key
result = GigaChatPassthroughConfig.get_api_key(api_key=None)
assert result == env_key
mock_get_secret.assert_called_once_with("GIGACHAT_API_KEY")
def test_get_base_model_returns_model(self):
"""Test get_base_model returns the model as-is."""
model = "gigachat/GigaChat"
result = GigaChatPassthroughConfig.get_base_model(model)
assert result == model
def test_get_models(self):
"""Test get_models delegates to base class."""
config = GigaChatPassthroughConfig()
result = config.get_models()
assert result == []
def test_logging_non_streaming_chat_raises_when_no_config(self):
"""Test raise when ProviderConfigManager returns None for chat."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
with patch(
"litellm.utils.ProviderConfigManager.get_provider_chat_config", # test-quality-ok: patching litellm internal for unit test isolation
return_value=None,
):
with pytest.raises(ValueError, match="No provider config found for model"):
config.logging_non_streaming_response(
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
httpx_response=_make_httpx_response(_gigachat_chat_completion_body()),
request_data={
"model": "gigachat/GigaChat",
"messages": [{"role": "user", "content": "hi"}],
},
logging_obj=logging_obj,
endpoint="chat/completions",
)
def test_logging_non_streaming_embedding_raises_when_no_config(self):
"""Test raise when ProviderConfigManager returns None for embeddings."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
with patch(
"litellm.utils.ProviderConfigManager.get_provider_embedding_config", # test-quality-ok: patching litellm internal for unit test isolation
return_value=None,
):
with pytest.raises(ValueError, match="No provider config found for model"):
config.logging_non_streaming_response(
model="gigachat/Embeddings",
custom_llm_provider="gigachat",
httpx_response=_make_httpx_response(_gigachat_embedding_body()),
request_data={
"input": ["hello"],
"model": "gigachat/Embeddings",
},
logging_obj=logging_obj,
endpoint="embeddings",
)
def test_handle_logging_collected_chunks_with_model_response_stream_chunk(self):
"""Test that a chunk returning ModelResponseStream from chunk_parser is handled.
Requires patching GigaChatModelResponseIterator.chunk_parser to return
a ModelResponseStream so the elif branch is exercised.
"""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
from litellm.types.utils import ModelResponseStream
stream_chunk = ModelResponseStream(
choices=[
{
"index": 0,
"delta": {"content": "streamed"},
"finish_reason": None,
}
]
)
chunks = [
'{"choices": [{"delta": {"content": "streamed"}, "index": 0}]}',
'{"choices": [{"delta": {}, "finish_reason": "stop", "index": 0}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}',
]
with patch(
"litellm.llms.gigachat.passthrough.transformation.GigaChatModelResponseIterator.chunk_parser", # test-quality-ok: patching litellm internal for unit test isolation
return_value=stream_chunk,
):
result = config.handle_logging_collected_chunks(
all_chunks=chunks,
litellm_logging_obj=logging_obj,
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
endpoint="chat/completions",
)
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "streamedstreamed"
def test_handle_logging_collected_chunks_skips_unknown_chunk_type(self):
"""Test that chunk_parser returning an unknown type is skipped."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
chunks = [
'{"choices": [{"delta": {"content": "good"}, "index": 0}]}',
'{"choices": [{"delta": {}, "finish_reason": "stop", "index": 0}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}',
]
with patch(
"litellm.llms.gigachat.passthrough.transformation.GigaChatModelResponseIterator.chunk_parser", # test-quality-ok: patching litellm internal for unit test isolation
return_value=12345, # not dict and not ModelResponseStream
):
result = config.handle_logging_collected_chunks(
all_chunks=chunks,
litellm_logging_obj=logging_obj,
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
endpoint="chat/completions",
)
# All chunks skipped, returns None
assert result is None
def test_handle_logging_collected_chunks_skips_unsupported_chunk_type(self):
"""Test that unsupported chunk types (non-JSON str) are skipped."""
config = GigaChatPassthroughConfig()
logging_obj = MagicMock()
# Both are valid str chunks; "not-a-valid-json" fails json.loads, int is not a str
chunks: list[str] = ["not-a-valid-json"]
result = config.handle_logging_collected_chunks(
all_chunks=chunks,
litellm_logging_obj=logging_obj,
model="gigachat/GigaChat",
custom_llm_provider="gigachat",
endpoint="chat/completions",
)
assert result is None

View file

@ -0,0 +1,494 @@
"""
Unit tests for GigaChat OAuth authenticator.
Tests get_access_token and get_access_token_async covering token resolution
from litellm_params/env, credential validation, caching, and error handling.
"""
import time
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from litellm.llms.gigachat import authenticator
from litellm.llms.gigachat.authenticator import (
GigaChatAuthError,
TOKEN_EXPIRY_BUFFER_MS,
get_access_token,
get_access_token_async,
)
AUTH_MODULE = "litellm.llms.gigachat.authenticator"
def _future_expires_at_ms(offset_seconds: float = 3600) -> int:
return int(time.time() * 1000 + offset_seconds * 1000)
def _past_expires_at_ms(offset_seconds: float = 3600) -> int:
return int(time.time() * 1000 - offset_seconds * 1000)
@pytest.fixture(autouse=True)
def _isolate_token_cache():
"""Each test gets a fresh module-level token cache to avoid cross-test leakage."""
with patch(f"{AUTH_MODULE}._token_cache", new=MagicMock()):
authenticator._token_cache.get_cache.return_value = None
authenticator._token_cache.set_cache = MagicMock()
yield
class TestGetAccessTokenSync:
def test_returns_token_from_litellm_params(self):
token = get_access_token(litellm_params={"gigachat_access_token": "param-token"})
assert token == "param-token"
authenticator._token_cache.get_cache.assert_not_called()
@patch(f"{AUTH_MODULE}.get_secret_str")
def test_returns_token_from_env(self, mock_get_secret):
mock_get_secret.return_value = "env-access-token"
token = get_access_token()
assert token == "env-access-token"
@patch(f"{AUTH_MODULE}._get_credentials", return_value=None)
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
def test_raises_when_no_credentials(self, mock_get_secret, mock_get_creds):
with pytest.raises(GigaChatAuthError) as exc_info:
get_access_token()
assert exc_info.value.status_code == 401
assert "credentials not provided" in exc_info.value.message
@patch(f"{AUTH_MODULE}._request_token_sync")
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value=None)
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
def test_raises_when_no_credentials_even_with_other_resolvers(
self, mock_get_secret, mock_get_creds, mock_scope, mock_auth_url, mock_request
):
with pytest.raises(GigaChatAuthError) as exc_info:
get_access_token()
assert exc_info.value.status_code == 401
mock_request.assert_not_called()
@patch(f"{AUTH_MODULE}._request_token_sync")
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds-from-env")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
def test_requests_new_token_and_caches(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request):
token = "fresh-token"
expires_at = _future_expires_at_ms()
mock_request.return_value = (token, expires_at)
result = get_access_token()
assert result == token
mock_request.assert_called_once_with("creds-from-env", "GIGACHAT_API_PERS", "https://auth.example.com")
authenticator._token_cache.set_cache.assert_called_once()
call_args = authenticator._token_cache.set_cache.call_args
assert call_args.args[1] == (token, expires_at)
assert call_args.kwargs["ttl"] > 0
@patch(f"{AUTH_MODULE}._request_token_sync")
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
def test_does_not_cache_when_no_expiry(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request):
mock_request.return_value = ("token-no-exp", 0)
result = get_access_token()
assert result == "token-no-exp"
authenticator._token_cache.set_cache.assert_not_called()
@patch(f"{AUTH_MODULE}._request_token_sync")
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
def test_does_not_cache_when_ttl_non_positive(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request):
expires_at = int(time.time() * 1000) + TOKEN_EXPIRY_BUFFER_MS - 1000
mock_request.return_value = ("token", expires_at)
result = get_access_token()
assert result == "token"
authenticator._token_cache.set_cache.assert_not_called()
@patch(f"{AUTH_MODULE}._request_token_sync")
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
def test_returns_cached_valid_token(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request):
cached_token = "cached-token"
cached_expires_at = _future_expires_at_ms(offset_seconds=7200)
authenticator._token_cache.get_cache.return_value = (cached_token, cached_expires_at)
result = get_access_token(credentials="creds")
assert result == cached_token
mock_request.assert_not_called()
authenticator._token_cache.set_cache.assert_not_called()
@patch(f"{AUTH_MODULE}._request_token_sync")
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
def test_requests_new_token_when_cache_expired(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request):
cached_token = "stale-token"
cached_expires_at = _past_expires_at_ms(offset_seconds=10)
authenticator._token_cache.get_cache.return_value = (cached_token, cached_expires_at)
new_token = "refreshed-token"
mock_request.return_value = (new_token, _future_expires_at_ms())
result = get_access_token(credentials="creds")
assert result == new_token
mock_request.assert_called_once()
@patch(f"{AUTH_MODULE}._request_token_sync")
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://default-auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="env-creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
def test_litellm_params_override_scope_and_auth_url(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request): # test-quality-ok: mock-echo of internal wiring
mock_request.return_value = ("token", _future_expires_at_ms())
get_access_token(
litellm_params={
"gigachat_scope": "GIGACHAT_API_CORP",
"gigachat_auth_url": "https://params-auth.example.com",
}
)
mock_request.assert_called_once_with( # test-quality-ok: mock-echo of internal wiring
"env-creds", "GIGACHAT_API_CORP", "https://params-auth.example.com"
)
@patch(f"{AUTH_MODULE}._request_token_sync")
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://default-auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="env-creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
def test_explicit_args_override_everything(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request): # test-quality-ok: mock-echo of internal wiring
mock_request.return_value = ("token", _future_expires_at_ms())
get_access_token(
credentials="explicit-creds",
scope="EXPLICIT_SCOPE",
auth_url="https://explicit.example.com",
litellm_params={
"gigachat_scope": "PARAM_SCOPE",
"gigachat_auth_url": "https://params.example.com",
},
)
mock_request.assert_called_once_with( # test-quality-ok: mock-echo of internal wiring
"explicit-creds", "EXPLICIT_SCOPE", "https://explicit.example.com"
)
@patch(f"{AUTH_MODULE}._request_token_sync")
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
def test_propagates_auth_error_from_request(self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request):
mock_request.side_effect = GigaChatAuthError(status_code=403, message="forbidden")
with pytest.raises(GigaChatAuthError) as exc_info:
get_access_token()
assert exc_info.value.status_code == 403
assert exc_info.value.message == "forbidden"
class TestGetAccessTokenAsync:
@pytest.mark.asyncio
async def test_returns_token_from_litellm_params(self):
token = await get_access_token_async(
litellm_params={"gigachat_access_token": "param-token"}
)
assert token == "param-token"
authenticator._token_cache.get_cache.assert_not_called()
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}.get_secret_str")
async def test_returns_token_from_env(self, mock_get_secret):
mock_get_secret.return_value = "env-access-token"
token = await get_access_token_async()
assert token == "env-access-token"
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}._get_credentials", return_value=None)
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
async def test_raises_when_no_credentials(self, mock_get_secret, mock_get_creds):
with pytest.raises(GigaChatAuthError) as exc_info:
await get_access_token_async()
assert exc_info.value.status_code == 401
assert "credentials not provided" in exc_info.value.message
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock)
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds-from-env")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
async def test_requests_new_token_and_caches(
self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request
):
token = "fresh-token-async"
expires_at = _future_expires_at_ms()
mock_request.return_value = (token, expires_at)
result = await get_access_token_async()
assert result == token
mock_request.assert_called_once_with(
"creds-from-env", "GIGACHAT_API_PERS", "https://auth.example.com"
)
authenticator._token_cache.set_cache.assert_called_once()
call_args = authenticator._token_cache.set_cache.call_args
assert call_args.args[1] == (token, expires_at)
assert call_args.kwargs["ttl"] > 0
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock)
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
async def test_does_not_cache_when_no_expiry(
self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request
):
mock_request.return_value = ("token-no-exp", 0)
result = await get_access_token_async()
assert result == "token-no-exp"
authenticator._token_cache.set_cache.assert_not_called()
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock)
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
async def test_returns_cached_valid_token(
self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request
):
cached_token = "cached-token-async"
cached_expires_at = _future_expires_at_ms(offset_seconds=7200)
authenticator._token_cache.get_cache.return_value = (cached_token, cached_expires_at)
result = await get_access_token_async(credentials="creds")
assert result == cached_token
mock_request.assert_not_called()
authenticator._token_cache.set_cache.assert_not_called()
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock)
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
async def test_requests_new_token_when_cache_expired(
self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request
):
cached_expires_at = _past_expires_at_ms(offset_seconds=10)
authenticator._token_cache.get_cache.return_value = ("stale", cached_expires_at)
new_token = "refreshed-token-async"
mock_request.return_value = (new_token, _future_expires_at_ms())
result = await get_access_token_async(credentials="creds")
assert result == new_token
mock_request.assert_called_once()
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock)
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://default-auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="env-creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
async def test_litellm_params_override_scope_and_auth_url( # test-quality-ok: mock-echo of internal wiring
self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request
):
mock_request.return_value = ("token", _future_expires_at_ms())
await get_access_token_async(
litellm_params={
"gigachat_scope": "GIGACHAT_API_CORP",
"gigachat_auth_url": "https://params-auth.example.com",
}
)
mock_request.assert_called_once_with( # test-quality-ok: mock-echo of internal wiring
"env-creds", "GIGACHAT_API_CORP", "https://params-auth.example.com"
)
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock)
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://default-auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="env-creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
async def test_explicit_args_override_everything( # test-quality-ok: mock-echo of internal wiring
self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request
):
mock_request.return_value = ("token", _future_expires_at_ms())
await get_access_token_async(
credentials="explicit-creds",
scope="EXPLICIT_SCOPE",
auth_url="https://explicit.example.com",
litellm_params={
"gigachat_scope": "PARAM_SCOPE",
"gigachat_auth_url": "https://params.example.com",
},
)
mock_request.assert_called_once_with( # test-quality-ok: mock-echo of internal wiring
"explicit-creds", "EXPLICIT_SCOPE", "https://explicit.example.com"
)
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}._request_token_async", new_callable=AsyncMock)
@patch(f"{AUTH_MODULE}._get_auth_url", return_value="https://auth.example.com")
@patch(f"{AUTH_MODULE}._get_scope", return_value="GIGACHAT_API_PERS")
@patch(f"{AUTH_MODULE}._get_credentials", return_value="creds")
@patch(f"{AUTH_MODULE}.get_secret_str", return_value=None)
async def test_propagates_auth_error_from_request(
self, mock_get_secret, mock_creds, mock_scope, mock_auth_url, mock_request
):
mock_request.side_effect = GigaChatAuthError(status_code=403, message="forbidden")
with pytest.raises(GigaChatAuthError) as exc_info:
await get_access_token_async()
assert exc_info.value.status_code == 403
assert exc_info.value.message == "forbidden"
class TestRequestTokenSyncErrorMapping:
@patch(f"{AUTH_MODULE}._get_http_client")
def test_http_status_error_maps_to_auth_error(self, mock_get_client):
client = MagicMock()
request = httpx.Request("POST", "https://auth.example.com")
response = httpx.Response(status_code=401, content=b"bad creds", request=request)
http_error = httpx.HTTPStatusError("unauthorized", request=request, response=response)
client.post.side_effect = http_error
mock_get_client.return_value = client
from litellm.llms.gigachat.authenticator import _request_token_sync
with pytest.raises(GigaChatAuthError) as exc_info:
_request_token_sync("creds", "GIGACHAT_API_PERS", "https://auth.example.com")
assert exc_info.value.status_code == 401
assert "bad creds" in exc_info.value.message
@patch(f"{AUTH_MODULE}._get_http_client")
def test_request_error_maps_to_auth_error(self, mock_get_client):
client = MagicMock()
client.post.side_effect = httpx.ConnectError("connection refused")
mock_get_client.return_value = client
from litellm.llms.gigachat.authenticator import _request_token_sync
with pytest.raises(GigaChatAuthError) as exc_info:
_request_token_sync("creds", "GIGACHAT_API_PERS", "https://auth.example.com")
assert exc_info.value.status_code == 500
assert "connection refused" in exc_info.value.message
class TestRequestTokenAsyncErrorMapping:
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}.get_async_httpx_client")
async def test_http_status_error_maps_to_auth_error(self, mock_get_client):
client = MagicMock()
request = httpx.Request("POST", "https://auth.example.com")
response = httpx.Response(status_code=401, content=b"bad creds", request=request)
http_error = httpx.HTTPStatusError("unauthorized", request=request, response=response)
client.post = AsyncMock(side_effect=http_error)
mock_get_client.return_value = client
from litellm.llms.gigachat.authenticator import _request_token_async
with pytest.raises(GigaChatAuthError) as exc_info:
await _request_token_async("creds", "GIGACHAT_API_PERS", "https://auth.example.com")
assert exc_info.value.status_code == 401
assert "bad creds" in exc_info.value.message
@pytest.mark.asyncio
@patch(f"{AUTH_MODULE}.get_async_httpx_client")
async def test_request_error_maps_to_auth_error(self, mock_get_client):
client = MagicMock()
client.post = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
mock_get_client.return_value = client
from litellm.llms.gigachat.authenticator import _request_token_async
with pytest.raises(GigaChatAuthError) as exc_info:
await _request_token_async("creds", "GIGACHAT_API_PERS", "https://auth.example.com")
assert exc_info.value.status_code == 500
assert "connection refused" in exc_info.value.message
class TestParseTokenResponse:
def _make_response(self, body: dict) -> httpx.Response:
import json
return httpx.Response(
status_code=200,
content=json.dumps(body).encode("utf-8"),
request=httpx.Request("POST", "https://auth.example.com"),
)
def test_parses_tok_exp_fields(self):
from litellm.llms.gigachat.authenticator import _parse_token_response
token, expires_at = _parse_token_response(
self._make_response({"tok": "abc", "exp": 1700000000000})
)
assert token == "abc"
assert expires_at == 1700000000000
def test_parses_access_token_expires_at_fields(self):
from litellm.llms.gigachat.authenticator import _parse_token_response
token, expires_at = _parse_token_response(
self._make_response({"access_token": "xyz", "expires_at": 1700000000000})
)
assert token == "xyz"
assert expires_at == 1700000000000
def test_parses_string_expires_at(self):
from litellm.llms.gigachat.authenticator import _parse_token_response
token, expires_at = _parse_token_response(
self._make_response({"tok": "abc", "exp": "1700000000000"})
)
assert token == "abc"
assert expires_at == 1700000000000
assert isinstance(expires_at, int)
def test_raises_when_no_access_token(self):
from litellm.llms.gigachat.authenticator import _parse_token_response
with pytest.raises(GigaChatAuthError) as exc_info:
_parse_token_response(self._make_response({"exp": 1700000000000}))
assert exc_info.value.status_code == 500
assert "Invalid token response" in exc_info.value.message
class TestGetHttpClient:
def test_reuses_cached_client_across_calls(self):
"""Regression: the sync OAuth path must use the shared cached httpx client,
not construct a fresh HTTPHandler per token request."""
assert authenticator._get_http_client() is authenticator._get_http_client()

View file

@ -0,0 +1,504 @@
"""
Unit tests for GigaChat file handler.
Tests _get_url_hash, _parse_data_url, _download_image_sync, _download_image_async,
upload_file_sync, and upload_file_async covering caching, base64 data URL decoding,
network errors, and the full upload flow.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from litellm.llms.gigachat import file_handler
from litellm.llms.gigachat.file_handler import (
_file_cache,
_get_url_hash,
_parse_data_url,
upload_file_async,
upload_file_sync,
)
FILE_MODULE = "litellm.llms.gigachat.file_handler"
# A valid 1x1 red PNG as base64
_RED_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA"
"DUlEQVQI12NgYPgPAAEDAQAR3X3ZAAAASUVORK5CYII="
)
_RED_PNG_DATA_URL = f"data:image/png;base64,{_RED_PNG_B64}"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_file_cache():
"""Each test gets a fresh module-level file cache to avoid cross-test leakage."""
_file_cache.clear()
yield
_file_cache.clear()
# ---------------------------------------------------------------------------
# _get_url_hash
# ---------------------------------------------------------------------------
class TestGetUrlHash:
def test_returns_hex_string(self):
h = _get_url_hash("https://example.com/image.png")
assert isinstance(h, str)
assert len(h) == 64 # SHA-256
def test_different_urls_different_hashes(self):
h1 = _get_url_hash("https://example.com/a.png")
h2 = _get_url_hash("https://example.com/b.png")
assert h1 != h2
def test_same_url_same_hash(self):
h1 = _get_url_hash("https://example.com/image.png")
h2 = _get_url_hash("https://example.com/image.png")
assert h1 == h2
# ---------------------------------------------------------------------------
# _parse_data_url
# ---------------------------------------------------------------------------
class TestParseDataUrl:
def test_valid_base64_png(self):
result = _parse_data_url(_RED_PNG_DATA_URL)
assert result is not None
content_bytes, content_type, ext = result
assert content_type == "image/png"
assert ext == "png"
assert len(content_bytes) > 0
def test_valid_base64_jpeg(self):
# Simple valid base64 (24 chars, properly padded, no + or / chars)
valid_b64 = "aGVsbG8gd29ybGQhISEhIQ=="
data_url = f"data:image/jpeg;base64,{valid_b64}"
result = _parse_data_url(data_url)
assert result is not None
_, content_type, ext = result
assert content_type == "image/jpeg"
assert ext == "jpeg"
def test_valid_base64_with_semicolon_in_type(self):
"""Data URLs with charset before base64 segment do not match the regex."""
# The regex `data:([^;]+);base64,(.+)` requires the pattern to be
# `data:<type>;base64,<data>`. If `;charset=utf-8` appears before
# `;base64,`, the regex sees `data:image/png` as group 1 but then
# looks for `;base64,` immediately after — which isn't there because
# `;charset=utf-8;base64,` has extra text before `;base64,`
data_url = "data:image/png;charset=utf-8;base64," + _RED_PNG_B64
result = _parse_data_url(data_url)
assert result is None
def test_invalid_data_url_returns_none(self):
assert _parse_data_url("not-a-data-url") is None
def test_empty_base64_returns_none(self):
"""Empty base64 data (nothing after comma) does not match regex `(.+)`."""
assert _parse_data_url("data:image/png;base64,") is None
def test_missing_base64_segment(self):
assert _parse_data_url("data:image/png;base64") is None
def test_unknown_extension_falls_back_to_jpg(self):
data_url = "data:application/octet-stream;base64," + _RED_PNG_B64
result = _parse_data_url(data_url)
assert result is not None
_, content_type, ext = result
assert content_type == "application/octet-stream"
# The extension is derived from content_type.split("/")[-1].split(";")[0]
# which gives "octet-stream", not "jpg"
assert ext == "octet-stream"
# ---------------------------------------------------------------------------
# _download_image_sync
# ---------------------------------------------------------------------------
class TestDownloadImageSync:
@patch(f"{FILE_MODULE}._get_httpx_client")
def test_downloads_image_successfully(self, mock_http_handler_cls):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.content = b"fake-image-bytes"
mock_response.headers = {"content-type": "image/jpeg"}
mock_client.get.return_value = mock_response
mock_http_handler_cls.return_value = mock_client
content_bytes, content_type, ext = file_handler._download_image_sync("https://example.com/img.jpg")
assert content_bytes == b"fake-image-bytes"
assert content_type == "image/jpeg"
assert ext == "jpeg"
mock_client.get.assert_called_once_with("https://example.com/img.jpg")
@patch(f"{FILE_MODULE}._get_httpx_client")
def test_raises_on_http_error(self, mock_http_handler_cls):
mock_client = MagicMock()
mock_client.get.side_effect = httpx.HTTPStatusError(
"Not Found",
request=httpx.Request("GET", "https://example.com/404"),
response=httpx.Response(status_code=404, request=httpx.Request("GET", "https://example.com/404")),
)
mock_http_handler_cls.return_value = mock_client
with pytest.raises(httpx.HTTPStatusError):
file_handler._download_image_sync("https://example.com/404")
@patch(f"{FILE_MODULE}._get_httpx_client")
def test_parse_content_type_fallback(self, mock_http_handler_cls):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.content = b"data"
mock_response.headers = {}
mock_client.get.return_value = mock_response
mock_http_handler_cls.return_value = mock_client
_, content_type, ext = file_handler._download_image_sync("https://example.com/img")
assert content_type == "image/jpeg"
assert ext == "jpeg"
@patch(f"{FILE_MODULE}._get_httpx_client")
def test_extracts_extension_from_parametrized_type(self, mock_http_handler_cls):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.content = b"data"
mock_response.headers = {"content-type": "image/png; charset=utf-8"}
mock_client.get.return_value = mock_response
mock_http_handler_cls.return_value = mock_client
_, _, ext = file_handler._download_image_sync("https://example.com/img.png")
assert ext == "png"
# ---------------------------------------------------------------------------
# _download_image_async
# ---------------------------------------------------------------------------
class TestDownloadImageAsync:
@pytest.mark.asyncio
@patch(f"{FILE_MODULE}.get_async_httpx_client")
async def test_downloads_image_successfully(self, mock_get_client):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.content = b"fake-image-bytes"
mock_response.headers = {"content-type": "image/webp"}
mock_client.get = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
content_bytes, content_type, ext = await file_handler._download_image_async(
"https://example.com/img.webp"
)
assert content_bytes == b"fake-image-bytes"
assert content_type == "image/webp"
assert ext == "webp"
mock_client.get.assert_called_once_with("https://example.com/img.webp")
@pytest.mark.asyncio
@patch(f"{FILE_MODULE}.get_async_httpx_client")
async def test_raises_on_http_error(self, mock_get_client):
mock_client = MagicMock()
mock_client.get = AsyncMock(
side_effect=httpx.HTTPStatusError(
"Forbidden",
request=httpx.Request("GET", "https://example.com/403"),
response=httpx.Response(status_code=403, request=httpx.Request("GET", "https://example.com/403")),
)
)
mock_get_client.return_value = mock_client
with pytest.raises(httpx.HTTPStatusError):
await file_handler._download_image_async("https://example.com/403")
# ---------------------------------------------------------------------------
# upload_file_sync
# ---------------------------------------------------------------------------
class TestUploadFileSync:
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
@patch(f"{FILE_MODULE}.get_access_token", return_value="test-token")
@patch(f"{FILE_MODULE}._get_httpx_client")
def test_uploads_base64_image_and_caches(
self, mock_http_handler_cls, mock_get_token, mock_get_api_base
):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = {"id": "file-12345"}
mock_response.raise_for_status = MagicMock()
mock_client.post.return_value = mock_response
mock_http_handler_cls.return_value = mock_client
result = upload_file_sync(
image_url=_RED_PNG_DATA_URL,
credentials="creds",
api_base="https://custom.example.com",
)
assert result == "file-12345"
# Verify it was cached
url_hash = _get_url_hash(_RED_PNG_DATA_URL)
assert _file_cache[url_hash] == "file-12345"
# Check the upload request — url is passed as first positional arg
call_args = mock_client.post.call_args
assert call_args.args[0] == "https://api.example.com/files"
assert call_args.kwargs["headers"]["Authorization"] == "Bearer test-token"
# Verify purpose
assert call_args.kwargs["data"] == {"purpose": "general"}
# Verify a file was attached
assert "file" in call_args.kwargs["files"]
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
@patch(f"{FILE_MODULE}.get_access_token", return_value="test-token")
@patch(f"{FILE_MODULE}._get_httpx_client")
def test_returns_cached_file_id(
self, mock_http_handler_cls, mock_get_token, mock_get_api_base
):
# Pre-populate the cache
url_hash = _get_url_hash(_RED_PNG_DATA_URL)
_file_cache[url_hash] = "cached-file-id"
result = upload_file_sync(image_url=_RED_PNG_DATA_URL, credentials="creds")
assert result == "cached-file-id"
# No upload call was made
mock_http_handler_cls.return_value.post.assert_not_called()
@patch(f"{FILE_MODULE}._get_httpx_client")
@patch(f"{FILE_MODULE}.get_access_token", return_value="test-token")
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
@patch(f"{FILE_MODULE}._download_image_sync")
def test_downloads_and_uploads_url_image(
self, mock_download, mock_get_api_base, mock_get_token, mock_http_handler_cls
):
mock_download.return_value = (b"remote-bytes", "image/png", "png")
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = {"id": "file-remote"}
mock_response.raise_for_status = MagicMock()
mock_client.post.return_value = mock_response
mock_http_handler_cls.return_value = mock_client
result = upload_file_sync(
image_url="https://example.com/remote.png", credentials="creds"
)
assert result == "file-remote"
mock_download.assert_called_once_with("https://example.com/remote.png")
@patch(f"{FILE_MODULE}._get_httpx_client")
@patch(f"{FILE_MODULE}.get_access_token", return_value="test-token")
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
def test_returns_none_on_upload_failure(
self, mock_get_api_base, mock_get_token, mock_http_handler_cls
):
mock_client = MagicMock()
mock_client.post.side_effect = httpx.HTTPStatusError(
"Bad Request",
request=httpx.Request("POST", "https://api.example.com/files"),
response=httpx.Response(status_code=400, request=httpx.Request("POST", "https://api.example.com/files")),
)
mock_http_handler_cls.return_value = mock_client
# upload_file_sync catches all exceptions and returns None
result = upload_file_sync(
image_url=_RED_PNG_DATA_URL, credentials="creds"
)
assert result is None
@patch(f"{FILE_MODULE}._get_httpx_client")
@patch(f"{FILE_MODULE}.get_access_token", return_value="test-token")
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
def test_returns_none_when_response_missing_id(
self, mock_get_api_base, mock_get_token, mock_http_handler_cls
):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = {"status": "ok"} # no "id" key
mock_response.raise_for_status = MagicMock()
mock_client.post.return_value = mock_response
mock_http_handler_cls.return_value = mock_client
result = upload_file_sync(
image_url=_RED_PNG_DATA_URL, credentials="creds"
)
assert result is None
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
@patch(f"{FILE_MODULE}.get_access_token", return_value="test-token")
@patch(f"{FILE_MODULE}._get_httpx_client")
def test_uploads_without_optional_args(
self, mock_http_handler_cls, mock_get_token, mock_get_api_base
):
"""Verify that credentials, api_base, and litellm_params are optional."""
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = {"id": "file-no-args"}
mock_response.raise_for_status = MagicMock()
mock_client.post.return_value = mock_response
mock_http_handler_cls.return_value = mock_client
result = upload_file_sync(image_url=_RED_PNG_DATA_URL)
assert result == "file-no-args"
# Should still have called get_access_token without args
mock_get_token.assert_called_once_with(credentials=None, litellm_params=None)
# ---------------------------------------------------------------------------
# upload_file_async
# ---------------------------------------------------------------------------
class TestUploadFileAsync:
@pytest.mark.asyncio
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
@patch(f"{FILE_MODULE}.get_access_token_async", return_value="test-token-async")
@patch(f"{FILE_MODULE}.get_async_httpx_client")
async def test_uploads_base64_image_and_caches(
self, mock_get_client, mock_get_token, mock_get_api_base
):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.json = MagicMock(return_value={"id": "async-file-1"})
mock_response.raise_for_status = MagicMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await upload_file_async(
image_url=_RED_PNG_DATA_URL,
credentials="creds",
api_base="https://custom.example.com",
)
assert result == "async-file-1"
# Verify cache
url_hash = _get_url_hash(_RED_PNG_DATA_URL)
assert _file_cache[url_hash] == "async-file-1"
# Check upload request details — url is first positional arg
call_args = mock_client.post.call_args
assert call_args.args[0] == "https://api.example.com/files"
assert call_args.kwargs["headers"]["Authorization"] == "Bearer test-token-async"
assert "purpose" in str(call_args.kwargs["data"])
assert "file" in call_args.kwargs["files"]
@pytest.mark.asyncio
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
@patch(f"{FILE_MODULE}.get_access_token_async", return_value="test-token-async")
@patch(f"{FILE_MODULE}.get_async_httpx_client")
async def test_returns_cached_file_id(
self, mock_get_client, mock_get_token, mock_get_api_base
):
url_hash = _get_url_hash(_RED_PNG_DATA_URL)
_file_cache[url_hash] = "cached-async-id"
result = await upload_file_async(image_url=_RED_PNG_DATA_URL, credentials="creds")
assert result == "cached-async-id"
mock_get_client.return_value.post.assert_not_called()
@pytest.mark.asyncio
@patch(f"{FILE_MODULE}.get_async_httpx_client")
@patch(f"{FILE_MODULE}.get_access_token_async", return_value="test-token-async")
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
@patch(f"{FILE_MODULE}._download_image_async")
async def test_downloads_and_uploads_url_image(
self, mock_download, mock_get_api_base, mock_get_token, mock_get_client
):
mock_download.return_value = (b"remote-bytes-async", "image/png", "png")
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.json = MagicMock(return_value={"id": "async-file-remote"})
mock_response.raise_for_status = MagicMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await upload_file_async(
image_url="https://example.com/remote.png", credentials="creds"
)
assert result == "async-file-remote"
mock_download.assert_called_once_with("https://example.com/remote.png")
@pytest.mark.asyncio
@patch(f"{FILE_MODULE}.get_async_httpx_client")
@patch(f"{FILE_MODULE}.get_access_token_async", return_value="test-token-async")
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
async def test_returns_none_on_upload_failure(
self, mock_get_api_base, mock_get_token, mock_get_client
):
mock_client = MagicMock()
mock_client.post = AsyncMock(
side_effect=httpx.HTTPStatusError(
"Bad Request",
request=httpx.Request("POST", "https://api.example.com/files"),
response=httpx.Response(status_code=400, request=httpx.Request("POST", "https://api.example.com/files")),
)
)
mock_get_client.return_value = mock_client
result = await upload_file_async(
image_url=_RED_PNG_DATA_URL, credentials="creds"
)
assert result is None
@pytest.mark.asyncio
@patch(f"{FILE_MODULE}.get_async_httpx_client")
@patch(f"{FILE_MODULE}.get_access_token_async", return_value="test-token-async")
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
async def test_returns_none_when_response_missing_id(
self, mock_get_api_base, mock_get_token, mock_get_client
):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.json = MagicMock(return_value={"status": "ok"})
mock_response.raise_for_status = MagicMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await upload_file_async(
image_url=_RED_PNG_DATA_URL, credentials="creds"
)
assert result is None
@pytest.mark.asyncio
@patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
@patch(f"{FILE_MODULE}.get_access_token_async", return_value="test-token-async")
@patch(f"{FILE_MODULE}.get_async_httpx_client")
async def test_uploads_without_optional_args(
self, mock_get_client, mock_get_token, mock_get_api_base
):
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.json = MagicMock(return_value={"id": "async-no-args"})
mock_response.raise_for_status = MagicMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await upload_file_async(image_url=_RED_PNG_DATA_URL)
assert result == "async-no-args"
mock_get_token.assert_called_once_with(credentials=None, litellm_params=None)

View file

@ -0,0 +1,79 @@
"""
Tests for litellm.llms.gigachat.utils
"""
import pytest
from litellm.llms.gigachat.utils import convert_usage
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
class TestConvertUsage:
def test_basic_usage_without_precached(self):
"""Test convert_usage with standard tokens, no precached prompt tokens."""
result = convert_usage(
{
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
}
)
assert result == Usage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
prompt_tokens_details=None,
)
def test_usage_with_precached_prompt_tokens(self):
"""GigaChat's prompt_tokens and total_tokens exclude cached tokens (docs example:
prompt_tokens=1, precached_prompt_tokens=37, total_tokens=5), so OpenAI-convention
usage adds precached back in and surfaces it as cached_tokens."""
result = convert_usage(
{
"prompt_tokens": 10,
"completion_tokens": 5,
"precached_prompt_tokens": 3,
"total_tokens": 15,
}
)
assert result == Usage(
prompt_tokens=13,
completion_tokens=5,
total_tokens=18,
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=3),
)
def test_zero_precached_prompt_tokens(self):
"""Test convert_usage with zero precached_prompt_tokens does not create details wrapper."""
result = convert_usage(
{
"prompt_tokens": 10,
"completion_tokens": 5,
"precached_prompt_tokens": 0,
"total_tokens": 15,
}
)
assert result == Usage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
prompt_tokens_details=None,
)
def test_missing_optional_fields(self):
"""Test convert_usage with missing optional fields defaults to zero."""
result = convert_usage(
{
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
}
)
assert result.prompt_tokens == 10
assert result.completion_tokens == 5
assert result.total_tokens == 15
assert result.prompt_tokens_details is None

View file

@ -5,6 +5,7 @@ Tests for backend domain models.
from datetime import datetime
import pytest
from pydantic import BaseModel, TypeAdapter
from litellm.models.access_group import LiteLLM_AccessGroupTable
from litellm.models.budget import (
@ -130,6 +131,33 @@ class TestModel:
assert model.litellm_params == {"model": "gpt-4"}
assert model.model_info == {"team_id": "t1"}
def test_response_type_adapter_accepts_pydantic_row(self):
class PrismaModelRow(BaseModel):
model_id: str
model_name: str
litellm_params: dict[str, str]
model_info: dict[str, str] | None = None
blocked: bool = False
row = PrismaModelRow(
model_id="m1",
model_name="gpt-4",
litellm_params={"model": "gpt-4"},
model_info={"team_id": "t1"},
blocked=True,
)
model = TypeAdapter(LiteLLM_ProxyModelTable | None).validate_python(
row,
from_attributes=True,
)
assert model is not None
assert model.model_id == "m1"
assert model.litellm_params == {"model": "gpt-4"}
assert model.model_info == {"team_id": "t1"}
assert model.blocked is True
def test_team_helpers_none_when_no_model_info(self):
model = LiteLLM_ProxyModelTable(
model_id="m1", model_name="gpt-4", litellm_params={}, model_info=None

View file

@ -1,12 +1,12 @@
"""
Tests for error propagation in _async_streaming passthrough routes.
Tests for error propagation in async passthrough streaming routes.
Verifies that HTTP 4xx/5xx errors from upstream (e.g. Azure 429 rate limits)
raise exceptions instead of being silently forwarded as raw bytes under HTTP 200.
See: litellm/passthrough/main.py _async_streaming()
Verifies that streaming passthrough wrappers preserve the previous guarantees:
HTTP 4xx/5xx failures must raise instead of being silently forwarded as bytes,
and successful streaming responses should still yield chunks normally.
"""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
@ -54,19 +54,19 @@ def _make_mock_logging_obj():
@pytest.mark.asyncio
async def test_async_streaming_429_raises():
"""429 from upstream should raise HTTPStatusError, not yield error bytes."""
from litellm.passthrough.main import _async_streaming
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
error_body = json.dumps(
{"error": {"code": "429", "message": "Rate limit exceeded."}}
).encode()
mock_response = _make_mock_response(429, error_body)
async def response_coro():
return mock_response
chunks = []
async def _drain():
async for chunk in _async_streaming(
async for chunk in AsyncPassthroughStreamingResponse(
response=response_coro(),
litellm_logging_obj=_make_mock_logging_obj(),
provider_config=MagicMock(),
@ -83,45 +83,78 @@ async def test_async_streaming_429_raises():
@pytest.mark.asyncio
async def test_async_streaming_500_raises():
"""500 from upstream should also raise, not yield error bytes."""
from litellm.passthrough.main import _async_streaming
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
error_body = json.dumps(
{"error": {"code": "500", "message": "Internal server error"}}
).encode()
mock_response = _make_mock_response(500, error_body)
async def response_coro():
return mock_response
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async for _ in _async_streaming(
async for _ in AsyncPassthroughStreamingResponse(
response=response_coro(),
litellm_logging_obj=_make_mock_logging_obj(),
provider_config=MagicMock(),
):
pass
assert exc_info.value.response.status_code == 500
@pytest.mark.asyncio
async def test_async_streaming_200_yields_chunks():
async def test_async_passthrough_wrapper_200_yields_chunks():
"""Successful 200 streaming responses should continue to work normally."""
from litellm.passthrough.main import _async_streaming
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
sse_data = b'data: {"type":"response.created"}\n\ndata: [DONE]\n\n'
mock_response = _make_mock_response(200, sse_data)
mock_logging_obj = _make_mock_logging_obj()
async def response_coro():
return mock_response
chunks = []
async for chunk in _async_streaming(
async_stream = AsyncPassthroughStreamingResponse(
response=response_coro(),
litellm_logging_obj=_make_mock_logging_obj(),
litellm_logging_obj=mock_logging_obj,
provider_config=MagicMock(),
):
)
chunks = []
async for chunk in async_stream:
chunks.append(chunk)
await asyncio.sleep(0)
assert len(chunks) == 1
assert b"response.created" in chunks[0]
mock_logging_obj.async_flush_passthrough_collected_chunks.assert_awaited_once()
@pytest.mark.asyncio
async def test_error_body_readable_after_failed_await():
"""The upstream error body must stay readable so the proxy can map the real status and message."""
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
error_body = b'{"message":"model not found"}'
async def byte_stream():
yield error_body
request = httpx.Request("POST", "https://bedrock.example.com/model/x/converse-stream")
response = httpx.Response(400, content=byte_stream(), request=request)
async def response_coro():
return response
with pytest.raises(httpx.HTTPStatusError) as exc_info:
await AsyncPassthroughStreamingResponse(
response=response_coro(),
litellm_logging_obj=_make_mock_logging_obj(),
provider_config=MagicMock(),
)
assert exc_info.value.response.status_code == 400
assert await exc_info.value.response.aread() == error_body

View file

@ -645,13 +645,14 @@ async def test_allm_passthrough_route_429_streaming_raises():
Regression test: Azure 429 during streaming must raise HTTPStatusError,
not be silently forwarded as raw bytes under HTTP 200.
Before the fix, _async_streaming() would yield the 429 error JSON as
chunks and allm_passthrough_route returned an async generator. The
caller (azure_proxy_route) wrapped it in StreamingResponse(status_code=200),
Before the fix, the async passthrough streaming path would yield the 429
error JSON as chunks and allm_passthrough_route returned a streaming
iterator. The caller (azure_proxy_route) wrapped it in
StreamingResponse(status_code=200),
so the client saw HTTP 200 + unparseable SSE body silent task_complete(null).
After the fix, raise_for_status() fires inside _async_streaming() before
any chunks are yielded, so the exception propagates all the way up.
After the fix, raise_for_status() fires before the streaming wrapper is
returned, so the exception propagates all the way up.
"""
mock_provider_config = MagicMock()
mock_provider_config.get_complete_url.return_value = (
@ -679,6 +680,7 @@ async def test_allm_passthrough_route_429_streaming_raises():
mock_logging_obj = MagicMock()
mock_logging_obj.update_environment_variables = MagicMock()
mock_logging_obj.async_flush_passthrough_collected_chunks = AsyncMock()
mock_logging_obj.async_failure_handler = AsyncMock()
with (
patch(
@ -701,29 +703,101 @@ async def test_allm_passthrough_route_429_streaming_raises():
patch.object(async_client.client, "send", mock_send),
patch.object(async_client.client, "build_request", mock_build_request),
):
result = await allm_passthrough_route(
model="azure/gpt-4",
endpoint="openai/deployments/gpt-4/responses",
method="POST",
custom_llm_provider="azure",
api_base="https://my-azure.openai.azure.com",
api_key="fake-azure-key",
json={"model": "gpt-4", "input": "hello", "stream": True},
client=async_client,
litellm_logging_obj=mock_logging_obj,
)
# result is an async generator — consuming it must raise, not silently yield error bytes
chunks = []
async def _drain():
async for chunk in result: # type: ignore[union-attr]
chunks.append(chunk)
with pytest.raises(httpx.HTTPStatusError) as exc_info:
await _drain()
await allm_passthrough_route(
model="azure/gpt-4",
endpoint="openai/deployments/gpt-4/responses",
method="POST",
custom_llm_provider="azure",
api_base="https://my-azure.openai.azure.com",
api_key="fake-azure-key",
json={"model": "gpt-4", "input": "hello", "stream": True},
client=async_client,
litellm_logging_obj=mock_logging_obj,
)
assert exc_info.value.response.status_code == 429
assert len(chunks) == 0, "No chunks should be yielded before the 429 raises"
def test_llm_passthrough_route_sync_streaming_error_maps_upstream_status():
"""
Regression test: a sync streaming passthrough whose upstream answers an
error status must surface the mapped provider error, not
httpx.ResponseNotRead.
Before the fix, raise_for_status() raised on the still-unread streamed
response, and _handle_error then touched e.response.text, which raises
ResponseNotRead on a streamed-but-unread body, masking the real upstream
error entirely.
"""
from litellm.llms.base_llm.chat.transformation import BaseLLMException
error_body = json.dumps(
{
"error": {
"code": "429",
"message": "Rate limit exceeded. Retry after 10 seconds.",
}
}
).encode()
class _UnreadErrorStream(httpx.SyncByteStream):
def __iter__(self):
yield error_body
def _handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
429,
stream=_UnreadErrorStream(),
headers={"content-type": "application/json"},
)
sync_client = HTTPHandler(
client=httpx.Client(transport=httpx.MockTransport(_handler))
)
mock_provider_config = MagicMock()
mock_provider_config.get_complete_url.return_value = (
httpx.URL("https://gigachat.devices.sberbank.ru/api/v1/chat/completions"),
"https://gigachat.devices.sberbank.ru/api/v1",
)
mock_provider_config.get_api_key.return_value = "fake-key"
mock_provider_config.validate_environment.return_value = {
"Authorization": "Bearer fake-key"
}
mock_provider_config.sign_request.return_value = (
{"Authorization": "Bearer fake-key"},
None,
)
mock_provider_config.is_streaming_request.return_value = True
mock_provider_config.get_error_class.side_effect = (
lambda error_message, status_code, headers: BaseLLMException(
status_code=status_code, message=error_message, headers=headers
)
)
mock_logging_obj = MagicMock()
with pytest.raises(BaseLLMException) as exc_info:
llm_passthrough_route(
model="gigachat/GigaChat-2",
endpoint="chat/completions",
method="POST",
custom_llm_provider="gigachat",
api_base="https://gigachat.devices.sberbank.ru/api/v1",
api_key="fake-key",
json={
"model": "GigaChat-2",
"messages": [{"role": "user", "content": "hi"}],
"stream": True,
},
client=sync_client,
litellm_logging_obj=mock_logging_obj,
provider_config=mock_provider_config,
)
assert exc_info.value.status_code == 429
assert "Rate limit exceeded" in str(exc_info.value)
def test_llm_passthrough_route_propagates_allm_passthrough_route_to_logging_obj():

View file

@ -35,11 +35,14 @@ class _ImmediateExecutor:
@pytest.mark.asyncio
async def test_async_streaming_flushes_on_normal_completion():
from litellm.passthrough.main import _async_streaming
async def test_asyncpassthroughstreamingresponse_flushes_on_normal_completion():
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
chunks = [b"chunk-1", b"chunk-2", b"chunk-3"]
mock_response = _make_streaming_response(chunks)
mock_response.headers = httpx.Headers(
{"content-type": "application/octet-stream", "x-request-id": "req-123"}
)
async def response_coro():
return mock_response
@ -48,14 +51,19 @@ async def test_async_streaming_flushes_on_normal_completion():
provider_config = MagicMock()
received = []
async for chunk in _async_streaming(
received_response = AsyncPassthroughStreamingResponse(
response=response_coro(),
litellm_logging_obj=mock_logging_obj,
provider_config=provider_config,
):
)
async for chunk in received_response:
received.append(chunk)
assert received == chunks
assert received_response.headers["content-type"] == "application/octet-stream"
assert received_response.headers["x-request-id"] == "req-123"
await asyncio.sleep(0)
@ -68,8 +76,8 @@ async def test_async_streaming_flushes_on_normal_completion():
@pytest.mark.asyncio
async def test_async_streaming_flushes_on_client_disconnect():
from litellm.passthrough.main import _async_streaming
async def test_asyncpassthroughstreamingresponse_flushes_on_client_disconnect():
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
chunks = [
b'{"chunk": 1, "outputTokens": 10}',
@ -77,6 +85,9 @@ async def test_async_streaming_flushes_on_client_disconnect():
b'{"chunk": 3, "outputTokens": 8}',
]
mock_response = _make_streaming_response(chunks)
mock_response.headers = httpx.Headers(
{"content-type": "application/octet-stream", "x-request-id": "req-123"}
)
async def response_coro():
return mock_response
@ -84,7 +95,7 @@ async def test_async_streaming_flushes_on_client_disconnect():
mock_logging_obj = _make_logging_obj()
provider_config = MagicMock()
gen = _async_streaming(
gen = AsyncPassthroughStreamingResponse(
response=response_coro(),
litellm_logging_obj=mock_logging_obj,
provider_config=provider_config,
@ -105,11 +116,14 @@ async def test_async_streaming_flushes_on_client_disconnect():
@pytest.mark.asyncio
async def test_async_streaming_does_not_flush_on_4xx():
from litellm.passthrough.main import _async_streaming
async def test_asyncpassthroughstreamingresponse_does_not_flush_on_4xx():
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
err_response = MagicMock(spec=httpx.Response)
err_response.status_code = 429
err_response.headers = httpx.Headers(
{"content-type": "application/octet-stream"}
)
def _raise():
raise httpx.HTTPStatusError(
@ -129,7 +143,7 @@ async def test_async_streaming_does_not_flush_on_4xx():
mock_logging_obj = _make_logging_obj()
with pytest.raises(httpx.HTTPStatusError):
async for _ in _async_streaming(
async for _ in AsyncPassthroughStreamingResponse(
response=response_coro(),
litellm_logging_obj=mock_logging_obj,
provider_config=MagicMock(),
@ -140,8 +154,8 @@ async def test_async_streaming_does_not_flush_on_4xx():
@pytest.mark.asyncio
async def test_async_streaming_flushes_on_upstream_exception_with_partial_data():
from litellm.passthrough.main import _async_streaming
async def test_asyncpassthroughstreamingresponse_flushes_on_upstream_exception_with_partial_data():
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
partial_chunks = [b"partial-chunk-1", b"partial-chunk-2"]
@ -149,6 +163,9 @@ async def test_async_streaming_flushes_on_upstream_exception_with_partial_data()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock(return_value=None)
mock_response.aclose = AsyncMock()
mock_response.headers = httpx.Headers(
{"content-type": "application/octet-stream", "x-request-id": "req-123"}
)
async def _aiter_bytes_then_raise():
for c in partial_chunks:
@ -165,7 +182,7 @@ async def test_async_streaming_flushes_on_upstream_exception_with_partial_data()
received = []
async def _drain():
async for chunk in _async_streaming(
async for chunk in AsyncPassthroughStreamingResponse(
response=response_coro(),
litellm_logging_obj=mock_logging_obj,
provider_config=provider_config,
@ -186,12 +203,16 @@ async def test_async_streaming_flushes_on_upstream_exception_with_partial_data()
assert call_kwargs["raw_bytes"] == partial_chunks
def test_sync_streaming_flushes_on_normal_completion():
from litellm.passthrough.main import _sync_streaming
def test_passthroughstreamingresponse_flushes_on_normal_completion():
from litellm.passthrough.main import PassthroughStreamingResponse
chunks = [b"a", b"b", b"c"]
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = httpx.Headers(
{"content-type": "application/octet-stream", "x-request-id": "req-123"}
)
def _iter_bytes():
yield from chunks
@ -202,25 +223,33 @@ def test_sync_streaming_flushes_on_normal_completion():
mock_logging_obj.flush_passthrough_collected_chunks = MagicMock()
provider_config = MagicMock()
received_responce = PassthroughStreamingResponse(
response=mock_response,
litellm_logging_obj=mock_logging_obj,
provider_config=provider_config,
)
with patch("litellm.utils.executor", _ImmediateExecutor()):
received = list(
_sync_streaming(
response=mock_response,
litellm_logging_obj=mock_logging_obj,
provider_config=provider_config,
)
)
received = list(received_responce)
assert received == chunks
assert received_responce.headers["content-type"] == "application/octet-stream"
assert received_responce.headers["x-request-id"] == "req-123"
mock_logging_obj.flush_passthrough_collected_chunks.assert_called_once()
def test_sync_streaming_flushes_on_early_close():
from litellm.passthrough.main import _sync_streaming
def test_passthroughstreamingresponse_flushes_on_early_close():
from litellm.passthrough.main import PassthroughStreamingResponse
chunks = [b"first", b"second", b"third"]
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = httpx.Headers(
{"content-type": "application/octet-stream", "x-request-id": "req-123"}
)
def _iter_bytes():
yield from chunks
@ -232,7 +261,7 @@ def test_sync_streaming_flushes_on_early_close():
provider_config = MagicMock()
with patch("litellm.utils.executor", _ImmediateExecutor()):
gen = _sync_streaming(
gen = PassthroughStreamingResponse(
response=mock_response,
litellm_logging_obj=mock_logging_obj,
provider_config=provider_config,

View file

@ -8312,15 +8312,17 @@ async def test_key_does_not_override_explicit_budget_duration():
@patch(
"litellm.proxy.management_endpoints.key_management_endpoints.rotate_mcp_server_credentials_master_key"
)
async def test_rotate_master_key_model_data_valid_for_prisma(
async def test_rotate_master_key_reencrypts_model_params_in_place(
mock_rotate_mcp,
):
"""
Test that _rotate_master_key produces valid data for Prisma create_many().
Regression test for: master key rotation fails with Prisma validation error
because created_at/updated_at are None (non-nullable DateTime) and
litellm_params/model_info are JSON strings (create_many expects dicts).
Regression test for: master key rotation wipes every non-credential column
on LiteLLM_ProxyModelTable. Rotation used to rebuild the table via
delete_many + create_many from Deployment objects, which carry no
blocked/created_at/created_by/updated_at/updated_by, so every rotation
reset blocked to False (silently unblocking blocked models) and rewrote the
audit columns. Rotation must instead update only litellm_params (the sole
encrypted column) on each existing row, keyed by model_id.
"""
from unittest.mock import AsyncMock, MagicMock
@ -8352,6 +8354,7 @@ async def test_rotate_master_key_model_data_valid_for_prisma(
mock_tx.litellm_proxymodeltable = MagicMock()
mock_tx.litellm_proxymodeltable.delete_many = AsyncMock()
mock_tx.litellm_proxymodeltable.create_many = AsyncMock()
mock_tx.litellm_proxymodeltable.update_many = AsyncMock()
mock_prisma_client.db.tx = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_tx),
@ -8400,36 +8403,33 @@ async def test_rotate_master_key_model_data_valid_for_prisma(
new_master_key="sk-new-master-key",
)
# Verify create_many was called
mock_tx.litellm_proxymodeltable.create_many.assert_called_once()
# Rotation must never rewrite whole rows: no delete + recreate
mock_tx.litellm_proxymodeltable.delete_many.assert_not_called()
mock_tx.litellm_proxymodeltable.create_many.assert_not_called()
# Get the data passed to create_many
call_args = mock_tx.litellm_proxymodeltable.create_many.call_args
created_models = call_args.kwargs.get("data") or call_args[1].get("data")
mock_tx.litellm_proxymodeltable.update_many.assert_called_once()
call_args = mock_tx.litellm_proxymodeltable.update_many.call_args
assert len(created_models) == 1
model_data = created_models[0]
assert call_args.kwargs["where"] == {
"model_id": "model-1"
}, "the re-encrypted params must land on the same row, keyed by model_id"
# Verify timestamps are NOT present (Prisma @default(now()) should apply)
assert (
"created_at" not in model_data
), "created_at should be excluded so Prisma @default(now()) applies"
assert (
"updated_at" not in model_data
), "updated_at should be excluded so Prisma @default(now()) applies"
update_data = call_args.kwargs["data"]
assert set(update_data.keys()) == {"litellm_params"}, (
"rotation must touch only the encrypted litellm_params column; writing any "
f"other column wipes it (blocked, audit columns), got {sorted(update_data.keys())}"
)
# Verify litellm_params and model_info are prisma.Json wrappers, NOT JSON strings
import prisma
assert isinstance(
model_data["litellm_params"], prisma.Json
), f"litellm_params should be prisma.Json for create_many(), got {type(model_data['litellm_params'])}"
assert isinstance(
model_data["model_info"], prisma.Json
), f"model_info should be prisma.Json for create_many(), got {type(model_data['model_info'])}"
# Verify delete_many was called inside the transaction (before create_many)
mock_tx.litellm_proxymodeltable.delete_many.assert_called_once()
update_data["litellm_params"], prisma.Json
), f"litellm_params should be prisma.Json for update_many(), got {type(update_data['litellm_params'])}"
reencrypted_params = update_data["litellm_params"].data
assert set(reencrypted_params.keys()) >= {"model", "api_key"}
assert (
reencrypted_params["api_key"] != "sk-decrypted-key"
), "api_key must be stored re-encrypted under the new master key, not in plaintext"
async def test_default_key_generate_params_duration(monkeypatch):

View file

@ -4466,3 +4466,65 @@ class TestEnforceRpmTpmOnModelAdd:
_raise_if_rate_limits_required_but_missing(litellm_params=params, enforced=True)
assert expected_missing in str(exc_info.value.message)
assert exc_info.value.code == "400"
class TestBlockModelResponseSerialization:
@pytest.mark.parametrize(
("route", "blocked"), [("/model/block", True), ("/model/unblock", False)]
)
def test_block_routes_serialize_prisma_row_to_200(self, route, blocked):
from datetime import datetime, timezone
from prisma import models as prisma_models
import litellm.proxy.proxy_server as ps
from litellm.proxy.proxy_server import app
written_at = datetime(2026, 8, 29, tzinfo=timezone.utc)
row_fields = {
"model_id": "m-block-1",
"model_name": "gpt-4o-mini",
"litellm_params": json.dumps({"model": "openai/gpt-4o-mini", "api_key": "encrypted-value"}),
"model_info": json.dumps({"id": "m-block-1"}),
"created_at": written_at,
"created_by": "admin",
"updated_at": written_at,
"updated_by": "admin",
}
existing_row = prisma_models.LiteLLM_ProxyModelTable(blocked=not blocked, **row_fields)
updated_row = prisma_models.LiteLLM_ProxyModelTable(blocked=blocked, **row_fields)
mock_prisma = MagicMock()
mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=existing_row)
mock_prisma.db.litellm_proxymodeltable.update = AsyncMock(return_value=updated_row)
admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
app.dependency_overrides[ps.user_api_key_auth] = lambda: admin
try:
with (
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: proxy_server module global is the endpoint's only injection point
patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: proxy_server module global is the endpoint's only injection point
patch( # test-quality-ok: proxy_server module global is the endpoint's only injection point
"litellm.proxy.proxy_server.llm_router",
MagicMock(**{"get_model_ids.return_value": ["m-block-1"]}),
),
patch("litellm.proxy.proxy_server.redis_usage_cache", None), # test-quality-ok: proxy_server module global is the endpoint's only injection point
patch( # test-quality-ok: stubs the cache write so the test observes only response serialization
"litellm.proxy.management_endpoints.model_management_endpoints.clear_cache",
new=AsyncMock(return_value=ReconcileOutcome(still_desired=None, live_after=None)),
),
patch( # test-quality-ok: audit logging is a background side effect outside this test's contract
"litellm.proxy.management_endpoints.model_management_endpoints.create_object_audit_log",
new=AsyncMock(return_value=None),
),
):
client = TestClient(app)
response = client.post(route, json={"model_id": "m-block-1"})
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
body = response.json()
assert body["model_id"] == "m-block-1"
assert body["blocked"] is blocked
assert body["litellm_params"] == {"model": "openai/gpt-4o-mini", "api_key": "encrypted-value"}

View file

@ -12,11 +12,13 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
from fastapi import HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
from starlette.datastructures import FormData
import litellm
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
BaseOpenAIPassThroughHandler,
@ -30,6 +32,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
get_azure_ai_search_index_from_endpoint,
get_vertex_base_url,
is_azure_ai_search_service_level_index_create,
gigachat_proxy_route,
llm_passthrough_factory_proxy_route,
milvus_proxy_route,
mistral_proxy_route,
@ -178,7 +181,7 @@ class TestBaseOpenAIPassThroughHandler:
assert result["api-key"] == "test_api_key"
assert result["test-header"] == "value"
@patch(
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route"
)
async def test_base_openai_pass_through_handler(self, mock_create_pass_through):
@ -2022,15 +2025,15 @@ class TestLLMPassthroughFactoryProxyRoute:
class TestVLLMProxyRoute:
@pytest.mark.asyncio
@patch(
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body",
return_value={"model": "router-model", "stream": False},
)
@patch(
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model",
return_value=True,
)
@patch("litellm.proxy.proxy_server.llm_router")
@patch("litellm.proxy.proxy_server.llm_router") # test-quality-ok: patching litellm internal for unit test isolation
async def test_vllm_proxy_route_with_router_model(
self, mock_llm_router, mock_is_router, mock_get_body
):
@ -2055,15 +2058,15 @@ class TestVLLMProxyRoute:
mock_llm_router.allm_passthrough_route.assert_awaited_once()
@pytest.mark.asyncio
@patch(
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body",
return_value={"model": "other-model"},
)
@patch(
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model",
return_value=False,
)
@patch(
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.llm_passthrough_factory_proxy_route"
)
async def test_vllm_proxy_route_fallback_to_factory(
@ -2085,6 +2088,312 @@ class TestVLLMProxyRoute:
mock_factory_route.assert_awaited_once()
class TestGigachatProxyRoute:
@pytest.mark.asyncio
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body",
return_value={"model": "router-model", "stream": False},
)
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model",
return_value=True,
)
@patch("litellm.proxy.proxy_server.llm_router") # test-quality-ok: patching litellm internal for unit test isolation
async def test_gigachat_proxy_route_with_router_model(
self, mock_llm_router, mock_is_router, mock_get_body
):
mock_request = MagicMock(spec=Request)
mock_request.method = "POST"
mock_request.headers = {"content-type": "application/json"}
mock_request.query_params = {}
mock_fastapi_response = MagicMock(spec=Response)
mock_user_api_key_dict = MagicMock()
mock_llm_router.allm_passthrough_route = AsyncMock(
return_value=httpx.Response(200, json={"response": "success"})
)
result = await gigachat_proxy_route(
endpoint="/chat/completions",
request=mock_request,
fastapi_response=mock_fastapi_response,
user_api_key_dict=mock_user_api_key_dict,
)
mock_is_router.assert_called_once()
mock_llm_router.allm_passthrough_route.assert_awaited_once()
assert isinstance(result, Response)
@pytest.mark.asyncio
async def test_gigachat_router_handler_keeps_cached_body_and_payload_metadata_pristine(self):
"""Regression: auth-metadata injection must not leak into the cached parsed body or the upstream payload."""
from litellm.proxy.common_utils.http_parsing_utils import get_request_body
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
handle_gigachat_passthrough_router_model,
)
body = json.dumps(
{
"model": "gigachat-router",
"messages": [{"role": "user", "content": "hi"}],
"metadata": {"client_tag": "user-supplied"},
}
).encode()
scope = {
"type": "http",
"method": "POST",
"headers": [(b"content-type", b"application/json")],
"query_string": b"",
"path": "/gigachat/chat/completions",
}
async def receive():
return {"type": "http.request", "body": body, "more_body": False}
request = Request(scope, receive)
request_body = await get_request_body(request)
captured: dict = {}
class _CapturingProcessor:
def __init__(self, data: dict):
captured["data"] = data
async def base_passthrough_process_llm_request(self, **kwargs):
return Response(content=b"{}", status_code=200)
with patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing",
_CapturingProcessor,
):
await handle_gigachat_passthrough_router_model(
model="gigachat-router",
endpoint="/chat/completions",
request=request,
request_body=request_body,
fastapi_response=Response(),
llm_router=MagicMock(),
user_api_key_dict=UserAPIKeyAuth(user_id="user-1", team_id="team-1"),
proxy_logging_obj=MagicMock(),
general_settings={},
proxy_config=MagicMock(),
select_data_generator=MagicMock(),
user_model=None,
user_temperature=None,
user_request_timeout=None,
user_max_tokens=None,
user_api_base=None,
version=None,
)
data = captured["data"]
assert data["json"] is request_body
assert request_body["metadata"] == {"client_tag": "user-supplied"}
assert data["metadata"]["client_tag"] == "user-supplied"
assert data["metadata"]["user_api_key_user_id"] == "user-1"
assert data["metadata"]["user_api_key_team_id"] == "team-1"
cached_reread = await get_request_body(request)
assert cached_reread["metadata"] == {"client_tag": "user-supplied"}
@pytest.mark.asyncio
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body",
return_value={"model": "other-model"},
)
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model",
return_value=False,
)
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_streaming_request_fn",
new_callable=AsyncMock,
return_value=False,
)
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.llms.gigachat.authenticator.get_access_token",
return_value="gigachat-test-token",
)
async def test_gigachat_proxy_route_fallback_forwards_to_gigachat_api(
self,
mock_get_token,
mock_is_streaming,
mock_is_router,
mock_get_body,
monkeypatch,
):
monkeypatch.delenv("GIGACHAT_API_BASE", raising=False)
mock_request = MagicMock(spec=Request)
mock_fastapi_response = MagicMock(spec=Response)
mock_user_api_key_dict = MagicMock()
captured_kwargs = {}
async def fake_endpoint(request, fastapi_response, user_api_key_dict):
return Response(content=b'{"response": "success"}', status_code=200)
def fake_create_pass_through_route(**kwargs):
captured_kwargs.update(kwargs)
return fake_endpoint
with patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route",
side_effect=fake_create_pass_through_route,
):
result = await gigachat_proxy_route(
endpoint="/chat/completions",
request=mock_request,
fastapi_response=mock_fastapi_response,
user_api_key_dict=mock_user_api_key_dict,
)
assert isinstance(result, Response)
assert result.status_code == 200
assert captured_kwargs["target"] == "https://gigachat.devices.sberbank.ru/api/v1/chat/completions"
assert captured_kwargs["custom_headers"] == {"Authorization": "Bearer gigachat-test-token"}
@pytest.mark.asyncio
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body",
return_value={},
)
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_streaming_request_fn",
new_callable=AsyncMock,
return_value=False,
)
@patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.llms.gigachat.authenticator.get_access_token",
return_value="gigachat-test-token",
)
async def test_gigachat_proxy_route_models_endpoint_without_model(
self,
mock_get_token,
mock_is_streaming,
mock_get_body,
monkeypatch,
):
monkeypatch.delenv("GIGACHAT_API_BASE", raising=False)
mock_request = MagicMock(spec=Request)
mock_fastapi_response = MagicMock(spec=Response)
mock_user_api_key_dict = MagicMock()
captured_kwargs = {}
async def fake_endpoint(request, fastapi_response, user_api_key_dict):
return Response(content=b'{"data": []}', status_code=200)
def fake_create_pass_through_route(**kwargs):
captured_kwargs.update(kwargs)
return fake_endpoint
with patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route",
side_effect=fake_create_pass_through_route,
):
result = await gigachat_proxy_route(
endpoint="models",
request=mock_request,
fastapi_response=mock_fastapi_response,
user_api_key_dict=mock_user_api_key_dict,
)
assert isinstance(result, Response)
assert result.status_code == 200
assert captured_kwargs["target"] == "https://gigachat.devices.sberbank.ru/api/v1/models"
assert captured_kwargs["custom_headers"] == {"Authorization": "Bearer gigachat-test-token"}
@pytest.mark.asyncio
async def test_allm_passthrough_streaming_preserves_upstream_headers(self):
async def _stream() -> bytes:
yield b'data: {"id":"1"}\n\n'
class MockPassthroughStreamingResponse:
def __init__(self):
self.status_code = 201
self.headers = {
"content-type": "text/event-stream; charset=utf-8",
"x-request-id": "req-123",
"x-ratelimit-remaining-requests": "77",
"transfer-encoding": "chunked",
"content-encoding": "gzip",
}
self._iterator = _stream()
def __aiter__(self):
return self
async def __anext__(self):
return await self._iterator.__anext__()
processor = ProxyBaseLLMRequestProcessing(
data={
"model": "some-provider/model",
"stream": True,
"litellm_call_id": "call-123",
"litellm_logging_obj": MagicMock(litellm_call_id="call-123"),
}
)
mock_request = MagicMock(spec=Request)
mock_request.headers = {"content-type": "application/json"}
mock_fastapi_response = MagicMock(spec=Response)
mock_user_api_key_dict = MagicMock()
mock_user_api_key_dict.allowed_model_region = ""
mock_user_api_key_dict.spend = 0.0
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.during_call_hook = AsyncMock(return_value=None)
mock_proxy_logging_obj.update_request_status = AsyncMock(return_value=None)
mock_proxy_logging_obj.post_call_response_headers_hook = AsyncMock(
return_value={"x-test-callback-header": "callback-value"}
)
streaming_response = MockPassthroughStreamingResponse()
async def _fake_route_request(*args, **kwargs):
async def _inner():
return streaming_response
return _inner()
with patch.object(
processor,
"common_processing_pre_call_logic",
new=AsyncMock(
return_value=(
processor.data,
processor.data["litellm_logging_obj"],
)
),
), patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.common_request_processing.route_request",
new=_fake_route_request,
), patch( # test-quality-ok: patching litellm internal for unit test isolation
"litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing.get_custom_headers",
return_value={"x-litellm-call-id": "call-123"},
):
result = await processor.base_passthrough_process_llm_request(
request=mock_request,
fastapi_response=mock_fastapi_response,
user_api_key_dict=mock_user_api_key_dict,
proxy_logging_obj=mock_proxy_logging_obj,
general_settings={},
proxy_config=MagicMock(),
select_data_generator=MagicMock(),
llm_router=None,
model="some-provider/model",
version="test-version",
)
assert isinstance(result, StreamingResponse)
assert result.status_code == 201
assert result.headers["content-type"] == "text/event-stream; charset=utf-8"
assert result.headers["x-request-id"] == "req-123"
assert result.headers["x-ratelimit-remaining-requests"] == "77"
assert result.headers["x-litellm-call-id"] == "call-123"
assert result.headers["x-test-callback-header"] == "callback-value"
assert "transfer-encoding" not in result.headers
assert "content-encoding" not in result.headers
class TestForwardHeaders:
"""
Test cases for _forward_headers parameter in passthrough endpoints
@ -4627,3 +4936,76 @@ class TestPassthroughRouterModelBudgetReservation:
)
self._assert_metadata_carries_attribution(captured, user_api_key_dict)
class TestAzureRouterModelStreamingDispatch:
"""
Regression: ``llm_router.allm_passthrough_route`` returns an awaited
``AsyncPassthroughStreamingResponse`` for streaming calls, which is no
longer an async generator under ``inspect.isasyncgen``. The dispatch's
else branch therefore calls ``.aiter_bytes()`` / ``.status_code`` /
``.headers`` on it. The router's ``set_response_headers`` also runs the
result through ``prepare_response_for_header_attachment``, which used to
wrap it in ``HiddenParamsAsyncIteratorWrapper`` (no ``aiter_bytes``), so
every streaming Azure router-model request 500'd with
``AttributeError: aiter_bytes``; ``_hidden_params`` on the streaming
response keeps it unwrapped.
"""
@pytest.mark.asyncio
async def test_azure_router_model_streaming_returns_streaming_response(self, monkeypatch):
import litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
upstream_body = b"data: hello\n\n"
async def _upstream_response() -> httpx.Response:
upstream_request = httpx.Request(
"POST",
"https://my-azure.openai.azure.com/openai/deployments/gpt-5/chat/completions",
)
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
content=upstream_body,
request=upstream_request,
)
logging_obj = MagicMock()
logging_obj.async_flush_passthrough_collected_chunks = AsyncMock()
from litellm.router_utils.add_retry_fallback_headers import prepare_response_for_header_attachment
class StreamingRouter:
async def allm_passthrough_route(self, **kwargs):
streaming_response = await AsyncPassthroughStreamingResponse(
response=_upstream_response(),
litellm_logging_obj=logging_obj,
provider_config=MagicMock(),
)
return prepare_response_for_header_attachment(streaming_response)
async def fake_get_request_body(_request):
return {"model": "gpt-5", "stream": True}
monkeypatch.setattr(proxy_server, "llm_router", StreamingRouter())
monkeypatch.setattr(ep, "get_request_body", fake_get_request_body)
monkeypatch.setattr(ep, "is_passthrough_request_using_router_model", lambda *a, **k: True)
request = MagicMock(spec=Request)
request.method = "POST"
request.headers = {"content-type": "application/json"}
request.query_params = {}
result = await azure_proxy_route(
endpoint="openai/deployments/gpt-5/chat/completions",
request=request,
fastapi_response=MagicMock(spec=Response),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-token"),
)
assert isinstance(result, StreamingResponse)
assert result.status_code == 200
body = b"".join([chunk async for chunk in result.body_iterator])
assert body == upstream_body

View file

@ -4694,7 +4694,7 @@ class TestAllmPassthroughStreamingProviderGate:
}
return ProxyBaseLLMRequestProcessing(data=data)
async def _run(self, processing_obj, monkeypatch, chunks):
async def _run(self, processing_obj, monkeypatch, chunks, stream=None):
import litellm.proxy.common_request_processing as crp
from litellm.proxy._types import UserAPIKeyAuth as RealUserAPIKeyAuth
@ -4702,9 +4702,11 @@ class TestAllmPassthroughStreamingProviderGate:
for chunk in chunks:
yield chunk
upstream_stream = stream if stream is not None else streaming_response()
async def fake_route_request(**kwargs):
async def _llm_call():
return streaming_response()
return upstream_stream
return _llm_call()
@ -4729,6 +4731,40 @@ class TestAllmPassthroughStreamingProviderGate:
skip_pre_call_logic=True,
)
@pytest.mark.asyncio
async def test_client_disconnect_closes_unbuffered_passthrough_stream(self, monkeypatch):
"""Starlette abandons the body iterator when the client disconnects, so the
unbuffered passthrough branch must return _UpstreamClosingStreamingResponse,
whose shielded cleanup closes the upstream stream; that close is what flushes
buffered passthrough usage into spend logs."""
processing_obj = self._build_processing_obj("gigachat")
monkeypatch.setattr(litellm, "callbacks", [])
upstream_closed = asyncio.Event()
async def hanging_stream():
try:
yield b"chunk-1"
await asyncio.Event().wait()
finally:
upstream_closed.set()
result = await self._run(processing_obj, monkeypatch, [], stream=hanging_stream())
assert isinstance(result, _UpstreamClosingStreamingResponse)
first_chunk_sent = asyncio.Event()
async def receive():
await first_chunk_sent.wait()
return {"type": "http.disconnect"}
async def send(message):
if message["type"] == "http.response.body" and message.get("body"):
first_chunk_sent.set()
await result({"type": "http"}, receive, send)
await asyncio.wait_for(upstream_closed.wait(), timeout=5)
@pytest.mark.asyncio
async def test_non_bedrock_stream_is_not_buffered(self, monkeypatch):
processing_obj = self._build_processing_obj("anthropic")

View file

@ -4425,6 +4425,265 @@ class _DummyPlugin:
return context
class TestClassificationMode:
"""Test classification_mode='user_turn': classify only requests whose newest turn is a new
human ask; tool-loop continuation turns replay the session's held routing decision."""
REASONING_ASK = {
"role": "user",
"content": "Let's think step by step and reason through this problem carefully.",
}
SIMPLE_ASK = {"role": "user", "content": "Hello!"}
ASSISTANT_ANSWER = {"role": "assistant", "content": "the answer"}
TOOL_CALL_1 = {
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}],
}
TOOL_RESULT_1 = {"role": "tool", "tool_call_id": "call_1", "content": "file contents"}
TOOL_CALL_2 = {
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_2", "type": "function", "function": {"name": "run_tests", "arguments": "{}"}}],
}
TOOL_RESULT_2 = {"role": "tool", "tool_call_id": "call_2", "content": "3 passed"}
@pytest.fixture
def user_turn_config(self, basic_config) -> dict:
return {**basic_config, "classification_mode": "user_turn"}
@staticmethod
def _request_kwargs(session_id: str) -> dict:
return {"metadata": {"session_id": session_id}}
def _router(self, mock_router_instance, config: dict) -> ComplexityRouter:
mock_router_instance.cache = DualCache()
return ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
def _tool_loop_turns(self) -> list[list[dict]]:
return [
[self.REASONING_ASK],
[self.REASONING_ASK, self.TOOL_CALL_1, self.TOOL_RESULT_1],
[self.REASONING_ASK, self.TOOL_CALL_1, self.TOOL_RESULT_1, self.TOOL_CALL_2, self.TOOL_RESULT_2],
]
def test_default_mode_is_every_request(self, complexity_router):
assert complexity_router.config.classification_mode == "every_request"
def test_invalid_classification_mode_rejected(self, mock_router_instance, basic_config):
with pytest.raises(ValidationError):
ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config={**basic_config, "classification_mode": "sometimes"},
)
@pytest.mark.asyncio
async def test_user_turn_mode_classifies_tool_loop_once(self, mock_router_instance, user_turn_config):
"""The mutation check: a 3-request tool loop drives exactly one classification, and both
continuation turns hold the classified model under the user_turn_continuation cause."""
router = self._router(mock_router_instance, user_turn_config)
with patch.object(router, "_classify_and_route", wraps=router._classify_and_route) as spy:
responses = [
await router.async_pre_routing_hook(
model="test-model", request_kwargs=self._request_kwargs("loop-1"), messages=turn
)
for turn in self._tool_loop_turns()
]
assert spy.call_count == 1
assert [r.model for r in responses] == ["o1-preview", "o1-preview", "o1-preview"]
assert [r.routing_decision["cause"] for r in responses[1:]] == [
"user_turn_continuation",
"user_turn_continuation",
]
@pytest.mark.asyncio
async def test_every_request_default_classifies_every_tool_loop_turn(self, mock_router_instance, basic_config):
"""Pins today's default: every request classifies, including tool-loop continuations."""
router = self._router(mock_router_instance, basic_config)
with patch.object(router, "_classify_and_route", wraps=router._classify_and_route) as spy:
responses = [
await router.async_pre_routing_hook(
model="test-model", request_kwargs=self._request_kwargs("loop-2"), messages=turn
)
for turn in self._tool_loop_turns()
]
assert spy.call_count == 3
assert [r.model for r in responses] == ["o1-preview", "o1-preview", "o1-preview"]
assert all(r.routing_decision["cause"] != "user_turn_continuation" for r in responses)
@pytest.mark.asyncio
async def test_continuation_without_session_id_still_classifies(self, mock_router_instance, user_turn_config):
"""No resolvable session id means no held decision to replay, so every request classifies."""
router = self._router(mock_router_instance, user_turn_config)
with patch.object(router, "_classify_and_route", wraps=router._classify_and_route) as spy:
responses = [
await router.async_pre_routing_hook(model="test-model", request_kwargs={}, messages=turn)
for turn in self._tool_loop_turns()
]
assert spy.call_count == 3
assert [r.model for r in responses] == ["o1-preview", "o1-preview", "o1-preview"]
assert all(r.routing_decision["cause"] != "user_turn_continuation" for r in responses)
@pytest.mark.asyncio
async def test_plugins_suppress_user_turn_gate(self, mock_router_instance, basic_config):
"""A replayed decision would bypass the plugin pipeline, so plugins force every request
through _classify_and_route, exactly as they do for session_affinity."""
router = self._router(
mock_router_instance,
{**basic_config, "classification_mode": "user_turn", "plugins": [_DummyPlugin()]},
)
with patch.object(router, "_classify_and_route", wraps=router._classify_and_route) as spy:
responses = [
await router.async_pre_routing_hook(
model="test-model", request_kwargs=self._request_kwargs("loop-3"), messages=turn
)
for turn in self._tool_loop_turns()
]
assert spy.call_count == 3
assert [r.model for r in responses] == ["o1-preview", "o1-preview", "o1-preview"]
assert all(r.routing_decision["cause"] != "user_turn_continuation" for r in responses)
@pytest.mark.asyncio
async def test_new_human_ask_reclassifies_and_repins(self, mock_router_instance, user_turn_config):
"""Unlike session_affinity, a new human ask never short-circuits on the pin: the session
re-classifies, moves tier, and the moved decision becomes the next held decision."""
router = self._router(mock_router_instance, user_turn_config)
first = await router.async_pre_routing_hook(
model="test-model", request_kwargs=self._request_kwargs("s-repin"), messages=[self.REASONING_ASK]
)
second = await router.async_pre_routing_hook(
model="test-model",
request_kwargs=self._request_kwargs("s-repin"),
messages=[self.REASONING_ASK, self.ASSISTANT_ANSWER, self.SIMPLE_ASK],
)
third = await router.async_pre_routing_hook(
model="test-model",
request_kwargs=self._request_kwargs("s-repin"),
messages=[self.REASONING_ASK, self.ASSISTANT_ANSWER, self.SIMPLE_ASK, self.TOOL_CALL_1, self.TOOL_RESULT_1],
)
assert first.model == "o1-preview"
assert second.model == "gpt-4o-mini"
assert third.model == "gpt-4o-mini"
assert third.routing_decision["cause"] == "user_turn_continuation"
@pytest.mark.asyncio
async def test_new_ask_with_trailing_system_reminder_reclassifies(self, mock_router_instance, user_turn_config):
"""Claude Code appends a system-role reminder after the human turn; that trailing plumbing
must not turn a new ask into a continuation, and a continuation turn carrying the same
trailing reminder stays a continuation."""
router = self._router(mock_router_instance, user_turn_config)
reminder = {"role": "system", "content": "<total_tokens>100 tokens left</total_tokens>"}
first = await router.async_pre_routing_hook(
model="test-model", request_kwargs=self._request_kwargs("s-reminder"), messages=[self.REASONING_ASK]
)
second = await router.async_pre_routing_hook(
model="test-model",
request_kwargs=self._request_kwargs("s-reminder"),
messages=[self.REASONING_ASK, self.ASSISTANT_ANSWER, self.SIMPLE_ASK, reminder],
)
third = await router.async_pre_routing_hook(
model="test-model",
request_kwargs=self._request_kwargs("s-reminder"),
messages=[
self.REASONING_ASK,
self.ASSISTANT_ANSWER,
self.SIMPLE_ASK,
reminder,
self.TOOL_CALL_1,
self.TOOL_RESULT_1,
reminder,
],
)
assert first.model == "o1-preview"
assert second.model == "gpt-4o-mini"
assert second.routing_decision["cause"] != "user_turn_continuation"
assert third.model == "gpt-4o-mini"
assert third.routing_decision["cause"] == "user_turn_continuation"
@pytest.mark.asyncio
async def test_escalation_keyword_turn_is_a_new_ask(self, mock_router_instance, user_turn_config):
"""An escalation keyword arrives as human text, so the turn classifies and escalates
instead of replaying the held decision."""
router = self._router(mock_router_instance, user_turn_config)
first = await router.async_pre_routing_hook(
model="test-model", request_kwargs=self._request_kwargs("s-esc"), messages=[self.SIMPLE_ASK]
)
second = await router.async_pre_routing_hook(
model="test-model",
request_kwargs=self._request_kwargs("s-esc"),
messages=[self.SIMPLE_ASK, self.ASSISTANT_ANSWER, {"role": "user", "content": "LITELLM ESCALATE"}],
)
assert first.model == "gpt-4o-mini"
assert second.model == "gpt-4o"
assert second.routing_decision["escalated"] is True
@pytest.mark.asyncio
async def test_messages_surface_tool_result_shapes(self, mock_router_instance, user_turn_config):
"""Messages-surface shapes: a tool_result-only user turn is a continuation, while an ask
riding alongside a tool_result in the same turn is a new ask."""
router = self._router(mock_router_instance, user_turn_config)
tool_use = {"role": "assistant", "content": [{"type": "tool_use", "id": "x", "name": "t", "input": {}}]}
tool_result = {"type": "tool_result", "tool_use_id": "x", "content": "ok"}
first = await router.async_pre_routing_hook(
model="test-model", request_kwargs=self._request_kwargs("s-msgs"), messages=[self.REASONING_ASK]
)
pure = await router.async_pre_routing_hook(
model="test-model",
request_kwargs=self._request_kwargs("s-msgs"),
messages=[self.REASONING_ASK, tool_use, {"role": "user", "content": [tool_result]}],
)
hybrid = await router.async_pre_routing_hook(
model="test-model",
request_kwargs=self._request_kwargs("s-msgs"),
messages=[
self.REASONING_ASK,
tool_use,
{"role": "user", "content": [tool_result, {"type": "text", "text": "Hello!"}]},
],
)
assert first.model == "o1-preview"
assert pure.model == "o1-preview"
assert pure.routing_decision["cause"] == "user_turn_continuation"
assert hybrid.model == "gpt-4o-mini"
@pytest.mark.asyncio
async def test_session_affinity_wins_when_both_knobs_are_on(self, mock_router_instance, user_turn_config):
"""With session_affinity also on, the pin short-circuits new asks too and keeps its own
cause, so the session stays on turn 1's model."""
router = self._router(mock_router_instance, {**user_turn_config, "session_affinity": True})
first = await router.async_pre_routing_hook(
model="test-model", request_kwargs=self._request_kwargs("s-both"), messages=[self.REASONING_ASK]
)
second = await router.async_pre_routing_hook(
model="test-model",
request_kwargs=self._request_kwargs("s-both"),
messages=[self.REASONING_ASK, self.ASSISTANT_ANSWER, self.SIMPLE_ASK],
)
assert first.model == "o1-preview"
assert second.model == "o1-preview"
assert second.routing_decision["cause"] == "session_affinity_pin"
def test_user_turn_mode_enables_tier_and_deployment_pins(self, mock_router_instance, basic_config):
"""user_turn implies the tier pin machinery (the pin write is what gives a continuation
a held decision) and the tier pin implies the deployment pin; plugins suppress both."""
default = self._router(mock_router_instance, basic_config)
enabled = self._router(mock_router_instance, {**basic_config, "classification_mode": "user_turn"})
suppressed = self._router(
mock_router_instance,
{**basic_config, "classification_mode": "user_turn", "plugins": [_DummyPlugin()]},
)
assert default._uses_tier_pin is False
assert enabled._uses_tier_pin is True
assert enabled._uses_deployment_pin is True
assert suppressed._uses_tier_pin is False
assert suppressed._uses_deployment_pin is False
class TestRoutingPlugins:
"""Test the `complexity_router_config.plugins` field: narrows the classified
tier's candidate pool before a model is picked. Discussion:

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">
<path fill-rule="evenodd" clip-rule="evenodd" fill="#000001" d="M500,0c276.1218262,0,500,223.8403168,500,500
c0,276.1593628-223.8781738,500-500,500C223.8771973,1000,0,776.1218262,0,500C0.0000314,223.8771973,223.8771973,0.0000314,500,0z
M911.703125,378.8250122c-12.6343384,87.6312561-57.1812744,159.9468384-94.3469238,207.0031128
c-57.359314,70.65625-134.203064,131.828125-222.3842773,177.03125
c-87.2250366,44.7218628-181.578186,71.8875122-272.8406677,78.5562744
c-15.4475098,0.890625-29.7947083,1.3311768-43.2412415,1.3311768c-16.0025024,0-30.7562714-0.640625-44.759079-1.8999023
c73.1806183,57.3218384,165.3878326,91.499939,265.5440674,91.499939l-0.0562744-0.0562744
c238.0687561,0,431.1000061-192.999939,431.1000061-431.0686951c0-41.3156433-5.828125-81.2562561-16.6843872-119.0875244
C913.2562256,381.0218811,912.4812622,379.8999939,911.703125,378.8250122z M561.3343506,45.6000023
c-147.2843323,0-281.1668701,60.73843-358.1281128,162.4078064l-0.4881287,0.5968781
c-33.2709351,40.5328217-55.3865662,84.9996796-63.910614,128.6078033
c-4.8540802,26.53125-3.9568939,50.8968811,2.7127991,72.1312561v-0.0562439
c15.8215637,47.5031128,54.3062439,85.5968628,97.9543762,96.7875061
c18.1562653,4.5218506,35.6100006,6.4124756,51.8393707,5.671875l4.2859497-0.3812256
C405.9468689,502.4718628,504.34375,432.9312439,586.046875,366.046875
c51.1312256-42.7937622,100.0405884-99.4146729,103.1906128-135.9850006
C617.9468384,264.6322021,544.3500366,309.6121826,459.5,370.4156189
c-9.4125061,6.7062378-22.2406311,4.828125-29.2437439-4.2875061
c-42.1281128-54.5405884-78.8125305-98.8321838-115.53125-139.2943726
c-3.9968872-4.4449921-5.9906311-10.4062347-5.3987427-16.3302917c0.555603-5.9284515,3.6299744-11.4168854,8.4081116-14.9740753
C430.2281189,111.1612473,542.125,63.3568764,650.6875,53.2768745
C621.5718994,48.1650009,591.7124634,45.6006279,561.3343506,45.6000023z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -20,6 +20,7 @@ import falAiLogo from "../../public/assets/logos/fal_ai.jpg";
import featherlessLogo from "../../public/assets/logos/featherless.svg";
import fireworksLogo from "../../public/assets/logos/fireworks.svg";
import friendliLogo from "../../public/assets/logos/friendli.svg";
import gigachatLogo from "../../public/assets/logos/gigachat.svg";
import githubCopilotLogo from "../../public/assets/logos/github_copilot.svg";
import googleLogo from "../../public/assets/logos/google.svg";
import groqLogo from "../../public/assets/logos/groq.svg";
@ -107,6 +108,7 @@ export enum Providers {
FireworksAI = "Fireworks AI",
FRIENDLIAI = "Friendliai",
GALADRIEL = "Galadriel",
GIGACHAT = "GigaChat",
GITHUB_COPILOT = "Github Copilot",
Google_AI_Studio = "Google AI Studio",
GradientAI = "GradientAI",
@ -218,6 +220,7 @@ export const provider_map: Record<string, string> = {
FireworksAI: "fireworks_ai",
FRIENDLIAI: "friendliai",
GALADRIEL: "galadriel",
GIGACHAT: "gigachat",
GITHUB_COPILOT: "github_copilot",
Google_AI_Studio: "gemini",
GradientAI: "gradient_ai",
@ -323,6 +326,7 @@ export const providerLogoMap: Partial<Record<Providers, string>> = {
[Providers.FEATHERLESS_AI]: featherlessLogo.src,
[Providers.FireworksAI]: fireworksLogo.src,
[Providers.FRIENDLIAI]: friendliLogo.src,
[Providers.GIGACHAT]: gigachatLogo.src,
[Providers.GITHUB_COPILOT]: githubCopilotLogo.src,
[Providers.Google_AI_Studio]: googleLogo.src,
[Providers.Groq]: groqLogo.src,

View file

@ -89,6 +89,7 @@ const CONSTANT_CAUSE_LABELS: Record<string, string> = {
semantic_keyword_match: "Semantic keyword match",
session_affinity_pin: "Pinned to session",
session_affinity_escalation: "Escalated from session pin",
user_turn_continuation: "Continuation turn, classifier skipped",
quality_tier: "Quality tier mapping",
bandit: "Adaptive bandit",
default_fallback: "Default model, no route matched",

View file

@ -5258,6 +5258,42 @@ export interface paths {
patch?: never;
trace?: never;
};
"/gigachat/{endpoint}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Gigachat Proxy Route
* @description [Docs](https://docs.litellm.ai/docs/pass_through/gigachat)
*/
get: operations["gigachat_proxy_route_gigachat__endpoint__get"];
/**
* Gigachat Proxy Route
* @description [Docs](https://docs.litellm.ai/docs/pass_through/gigachat)
*/
put: operations["gigachat_proxy_route_gigachat__endpoint__put"];
/**
* Gigachat Proxy Route
* @description [Docs](https://docs.litellm.ai/docs/pass_through/gigachat)
*/
post: operations["gigachat_proxy_route_gigachat__endpoint__post"];
/**
* Gigachat Proxy Route
* @description [Docs](https://docs.litellm.ai/docs/pass_through/gigachat)
*/
delete: operations["gigachat_proxy_route_gigachat__endpoint__delete"];
options?: never;
head?: never;
/**
* Gigachat Proxy Route
* @description [Docs](https://docs.litellm.ai/docs/pass_through/gigachat)
*/
patch: operations["gigachat_proxy_route_gigachat__endpoint__patch"];
trace?: never;
};
"/global/activity": {
parameters: {
query?: never;
@ -34282,6 +34318,13 @@ export interface components {
adaptive_eligible: "all" | "classified_tier";
/** @description Quality vs cost weights for adaptive selection (used when adaptive=True) */
adaptive_weights?: components["schemas"]["AdaptiveRouterWeights"];
/**
* Classification Mode
* @description When to run the complexity classifier. 'every_request' (the default) classifies every inference request, including the tool-result continuation turns of an agentic loop. 'user_turn' classifies only requests whose newest turn is a new human ask and replays the session's held routing decision on continuation turns, which cuts classifier spend and eliminates mid-loop model switches. Continuations with no held decision to replay (no resolvable session_id, expired pin, fresh restart) still classify. Unlike session_affinity, a new human ask always re-classifies, so a session can still move tiers between asks. Suppressed when plugins are configured, for the same reason session_affinity is: a replayed decision would bypass the plugin pipeline.
* @default every_request
* @enum {string}
*/
classification_mode: "every_request" | "user_turn";
/**
* Classification Prompt
* @description Replaces the opening instructions of the LLM classifier rubric (the judging-criteria prose) for a custom tier set. The per-tier bullets and the trust-boundary paragraph telling the classifier to ignore tier requests embedded in quoted caller text are always appended after it and cannot be overridden. Requires tier_definitions; a built-in-tier router customizes its prompt via classifier_llm_config.system_prompt or classification_rubric instead.
@ -35554,7 +35597,7 @@ export interface components {
* Cause
* @enum {string}
*/
cause?: "heuristic_scorer" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "session_affinity_pin" | "session_affinity_escalation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
cause?: "heuristic_scorer" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
/** Classifier Cost */
classifier_cost?: number;
/** Classifier Model */
@ -46550,6 +46593,161 @@ export interface operations {
};
};
};
gigachat_proxy_route_gigachat__endpoint__get: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
gigachat_proxy_route_gigachat__endpoint__put: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
gigachat_proxy_route_gigachat__endpoint__post: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
gigachat_proxy_route_gigachat__endpoint__delete: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
gigachat_proxy_route_gigachat__endpoint__patch: {
parameters: {
query?: never;
header?: never;
path: {
endpoint: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_global_activity_global_activity_get: {
parameters: {
query?: {