mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/flaky-e2e-tests-d022f6
This commit is contained in:
commit
3eb1eee1fa
63 changed files with 5781 additions and 477 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
|
|||
"/comprehendmedical",
|
||||
"/cohere/",
|
||||
"/gemini/",
|
||||
"/gigachat/",
|
||||
"/google/",
|
||||
"/vertex_ai/",
|
||||
"/vertex-ai/",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ GigaChat Chat Module
|
|||
from .streaming import GigaChatModelResponseIterator
|
||||
from .transformation import GigaChatConfig, GigaChatError
|
||||
|
||||
__all__ = [
|
||||
__all__ = (
|
||||
"GigaChatConfig",
|
||||
"GigaChatError",
|
||||
"GigaChatModelResponseIterator",
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
7
litellm/llms/gigachat/passthrough/__init__.py
Normal file
7
litellm/llms/gigachat/passthrough/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
GigaChat passthrough Module
|
||||
"""
|
||||
|
||||
from .transformation import GigaChatPassthroughConfig
|
||||
|
||||
__all__ = ("GigaChatPassthroughConfig",)
|
||||
213
litellm/llms/gigachat/passthrough/transformation.py
Normal file
213
litellm/llms/gigachat/passthrough/transformation.py
Normal 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))
|
||||
26
litellm/llms/gigachat/utils.py
Normal file
26
litellm/llms/gigachat/utils.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -474,6 +474,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/vllm",
|
||||
"/mistral",
|
||||
"/milvus",
|
||||
"/gigachat",
|
||||
"/watsonx",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
53
tests/e2e/ui/tests/settings/scim.spec.ts
Normal file
53
tests/e2e/ui/tests/settings/scim.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
0
tests/test_litellm/endpoints/__init__.py
Normal file
0
tests/test_litellm/endpoints/__init__.py
Normal file
0
tests/test_litellm/endpoints/speech/__init__.py
Normal file
0
tests/test_litellm/endpoints/speech/__init__.py
Normal 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"}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
0
tests/test_litellm/llms/gigachat/__init__.py
Normal file
0
tests/test_litellm/llms/gigachat/__init__.py
Normal 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
|
||||
|
|
@ -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
|
||||
0
tests/test_litellm/llms/gigachat/embedding/__init__.py
Normal file
0
tests/test_litellm/llms/gigachat/embedding/__init__.py
Normal 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"
|
||||
0
tests/test_litellm/llms/gigachat/passthrough/__init__.py
Normal file
0
tests/test_litellm/llms/gigachat/passthrough/__init__.py
Normal 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
|
||||
494
tests/test_litellm/llms/gigachat/test_authenticator.py
Normal file
494
tests/test_litellm/llms/gigachat/test_authenticator.py
Normal 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()
|
||||
504
tests/test_litellm/llms/gigachat/test_file_handler.py
Normal file
504
tests/test_litellm/llms/gigachat/test_file_handler.py
Normal 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)
|
||||
79
tests/test_litellm/llms/gigachat/test_utils.py
Normal file
79
tests/test_litellm/llms/gigachat/test_utils.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
27
ui/litellm-dashboard/public/assets/logos/gigachat.svg
Normal file
27
ui/litellm-dashboard/public/assets/logos/gigachat.svg
Normal 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 |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
200
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
200
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue