mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
Merge branch 'litellm_internal_staging' of https://github.com/BerriAI/litellm into litellm_decrease_anys_opus5_r2
# Conflicts: # tests/logging_callback_tests/gcs_pub_sub_body/spend_logs_payload.json
This commit is contained in:
commit
38dc51cc28
88 changed files with 6911 additions and 548 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/",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ warnings.filterwarnings("ignore", message=".*conflict with protected namespace.*
|
|||
# Suppress Pydantic 2.11+ deprecation warning about accessing model_fields on instances
|
||||
# This warning can accumulate during streaming and cause memory leaks
|
||||
warnings.filterwarnings("ignore", message=".*Accessing the.*attribute on the instance is deprecated.*")
|
||||
# ReadOnly on TypedDict fields is repo-wide static discipline (LIT012); pydantic warns it
|
||||
# cannot enforce it at runtime, which floods proxy boot once such a type is schema-walked
|
||||
warnings.filterwarnings("ignore", message=".*`ReadOnly` qualifier.*")
|
||||
### INIT VARIABLES #########################
|
||||
import threading
|
||||
import os
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -108,7 +108,6 @@ class GoogleGenAIStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
|
||||
def __init__(self, completion_stream: object):
|
||||
self.sent_first_chunk = False
|
||||
# State tracking for accumulating partial tool calls
|
||||
self.accumulated_tool_calls = dict[int, _ToolCallAccumulator]()
|
||||
self._returned_response = False
|
||||
super().__init__(completion_stream)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -144,7 +144,6 @@ def _is_choice_non_empty(choice: StreamingChoices) -> bool:
|
|||
# Check model_extra for dynamically added fields on the choice
|
||||
choice_extra_fields: Final[Mapping[str, object]] = choice.model_extra or {}
|
||||
for extra_field_name, extra_field_value in choice_extra_fields.items():
|
||||
# Skip certain structural fields that are just default/None placeholders
|
||||
if extra_field_name == "index" and extra_field_value == 0:
|
||||
continue
|
||||
if extra_field_name in {"finish_reason", "logprobs"} and extra_field_value is None:
|
||||
|
|
@ -192,7 +191,6 @@ def _is_delta_non_empty(delta: Delta) -> bool:
|
|||
# Check model_extra for dynamically added fields (this is where Pydantic stores them)
|
||||
delta_extra_fields: Final[Mapping[str, object]] = delta.model_extra or {}
|
||||
for extra_field_value in delta_extra_fields.values():
|
||||
# Even structural fields are meaningful if they have actual content
|
||||
if _has_meaningful_content(extra_field_value):
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -859,12 +859,28 @@ class AnthropicMessagesHandler(BaseTranslation):
|
|||
|
||||
@staticmethod
|
||||
def _image_sources(block: Mapping[str, object]) -> tuple[str, ...]:
|
||||
"""Normalize an Anthropic image block into strings a guardrail can read.
|
||||
|
||||
base64 becomes a data URI so the format travels with the payload, which is what
|
||||
the OpenAI path already puts in this field. A file source yields nothing: those
|
||||
bytes live behind the Files API and this extractor has no client to fetch them.
|
||||
"""
|
||||
source: Final = block.get("source")
|
||||
if not isinstance(source, Mapping):
|
||||
return ()
|
||||
# Could be base64 or url
|
||||
|
||||
source_type: Final = source.get("type")
|
||||
if source_type == "url":
|
||||
url: Final = source.get("url")
|
||||
return (url,) if isinstance(url, str) and url else ()
|
||||
|
||||
data: Final = source.get("data")
|
||||
return (data,) if data else ()
|
||||
if not isinstance(data, str) or not data:
|
||||
return ()
|
||||
media_type: Final = source.get("media_type")
|
||||
if isinstance(media_type, str) and media_type:
|
||||
return (f"data:{media_type};base64,{data}",)
|
||||
return (data,)
|
||||
|
||||
async def _apply_guardrail_responses_to_input(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -7,13 +7,18 @@ This uses aws_sdk_bedrock_runtime for bidirectional streaming with Nova Sonic.
|
|||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from typing import Final, Protocol
|
||||
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import _redact_string, verbose_proxy_logger
|
||||
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.litellm_core_utils.realtime_streaming import DefaultLoggedRealTimeEventTypes
|
||||
from litellm.types.llms.openai import OpenAIRealtimeEvents
|
||||
from litellm.types.realtime import RealtimeResponseTransformInput
|
||||
|
||||
from ..base_aws_llm import BaseAWSLLM
|
||||
|
|
@ -32,6 +37,17 @@ def _json_str(value: JsonValue) -> str | None:
|
|||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _should_log_event(openai_message: Mapping[str, object]) -> bool:
|
||||
logged_types: Final = (
|
||||
litellm.logged_real_time_event_types
|
||||
if litellm.logged_real_time_event_types is not None
|
||||
else DefaultLoggedRealTimeEventTypes
|
||||
)
|
||||
if logged_types == "*":
|
||||
return True
|
||||
return openai_message.get("type") in logged_types
|
||||
|
||||
|
||||
class RealtimeClientWebSocket(Protocol):
|
||||
"""The client-facing websocket surface the realtime bridge talks to."""
|
||||
|
||||
|
|
@ -205,16 +221,22 @@ class BedrockRealtime(BaseAWSLLM):
|
|||
)
|
||||
)
|
||||
|
||||
bedrock_to_client_task: Final = asyncio.create_task(
|
||||
self._forward_bedrock_to_client(
|
||||
bedrock_stream,
|
||||
websocket,
|
||||
transformation_config,
|
||||
model,
|
||||
logging_obj,
|
||||
session_state,
|
||||
async def forward_bedrock_and_collect_logged_events() -> tuple[OpenAIRealtimeEvents, ...]:
|
||||
return tuple(
|
||||
[
|
||||
event
|
||||
async for event in self._forward_bedrock_to_client(
|
||||
bedrock_stream,
|
||||
websocket,
|
||||
transformation_config,
|
||||
model,
|
||||
logging_obj,
|
||||
session_state,
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
bedrock_to_client_task: Final = asyncio.create_task(forward_bedrock_and_collect_logged_events())
|
||||
|
||||
# Wait for both tasks to complete
|
||||
await asyncio.gather(
|
||||
|
|
@ -223,6 +245,27 @@ class BedrockRealtime(BaseAWSLLM):
|
|||
return_exceptions=True,
|
||||
)
|
||||
|
||||
forwarded_logged_events: Final = (
|
||||
bedrock_to_client_task.result()
|
||||
if not bedrock_to_client_task.cancelled() and bedrock_to_client_task.exception() is None
|
||||
else ()
|
||||
)
|
||||
logged_events: Final = (
|
||||
*forwarded_logged_events,
|
||||
*(
|
||||
leftover_event
|
||||
for leftover_event in transformation_config.leftover_usage_done_events()
|
||||
if _should_log_event(leftover_event)
|
||||
),
|
||||
)
|
||||
if logged_events:
|
||||
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(
|
||||
logging_obj.dispatch_success_handlers(
|
||||
list(logged_events), # mutable-ok: realtime spend logging requires a list result
|
||||
prefer_async_handlers=True,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error in BedrockRealtime.async_realtime: %s", e)
|
||||
try:
|
||||
|
|
@ -304,8 +347,8 @@ class BedrockRealtime(BaseAWSLLM):
|
|||
model: str,
|
||||
logging_obj: LiteLLMLogging,
|
||||
session_state: RealtimeResponseTransformInput,
|
||||
):
|
||||
"""Forward messages from Bedrock stream to client WebSocket."""
|
||||
) -> AsyncIterator[OpenAIRealtimeEvents]:
|
||||
"""Forward messages from Bedrock to the client, yielding the ones to record for spend logging."""
|
||||
try:
|
||||
while True:
|
||||
# Receive from Bedrock
|
||||
|
|
@ -353,11 +396,14 @@ class BedrockRealtime(BaseAWSLLM):
|
|||
)
|
||||
|
||||
# Send transformed messages to client
|
||||
openai_messages = transformed_response.get("response", [])
|
||||
response_value = transformed_response["response"]
|
||||
openai_messages = response_value if isinstance(response_value, list) else (response_value,)
|
||||
for openai_message in openai_messages:
|
||||
message_json = json.dumps(openai_message)
|
||||
await client_ws.send_text(message_json)
|
||||
verbose_proxy_logger.debug("Bedrock Realtime: Sent to client: %s", message_json[:200])
|
||||
if _should_log_event(openai_message):
|
||||
yield openai_message
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug("Bedrock to client forwarding ended: %s", e, exc_info=True)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Transforms between OpenAI Realtime API format and Bedrock Nova Sonic format.
|
|||
import base64
|
||||
import json
|
||||
import uuid as uuid_lib
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -20,29 +20,54 @@ from litellm.types.llms.openai import (
|
|||
OpenAIRealtimeContentPartDone,
|
||||
OpenAIRealtimeDoneEvent,
|
||||
OpenAIRealtimeEvents,
|
||||
OpenAIRealtimeInputAudioBufferSpeechEvent,
|
||||
OpenAIRealtimeInputAudioTranscriptionCompleted,
|
||||
OpenAIRealtimeInputAudioTranscriptionDelta,
|
||||
OpenAIRealtimeOutputItemDone,
|
||||
OpenAIRealtimeResponseAudioDone,
|
||||
OpenAIRealtimeResponseContentPartAdded,
|
||||
OpenAIRealtimeResponseDelta,
|
||||
OpenAIRealtimeResponseDoneObject,
|
||||
OpenAIRealtimeResponseTextDone,
|
||||
OpenAIRealtimeResponseUsage,
|
||||
OpenAIRealtimeStreamResponseBaseObject,
|
||||
OpenAIRealtimeStreamResponseOutputItemAdded,
|
||||
OpenAIRealtimeStreamSession,
|
||||
OpenAIRealtimeStreamSessionEvents,
|
||||
OpenAIRealtimeUsageTokenDetails,
|
||||
)
|
||||
from litellm.types.realtime import (
|
||||
ALL_DELTA_TYPES,
|
||||
RealtimeResponseTransformInput,
|
||||
RealtimeResponseTypedDict,
|
||||
)
|
||||
from litellm.utils import get_empty_usage
|
||||
|
||||
|
||||
class BedrockContentEnd(BaseModel):
|
||||
stopReason: str | None = None
|
||||
|
||||
|
||||
class BedrockUsageTokenDetails(BaseModel):
|
||||
speechTokens: int = 0
|
||||
textTokens: int = 0
|
||||
|
||||
|
||||
class BedrockUsageDetailsTotal(BaseModel):
|
||||
input: BedrockUsageTokenDetails = BedrockUsageTokenDetails()
|
||||
output: BedrockUsageTokenDetails = BedrockUsageTokenDetails()
|
||||
|
||||
|
||||
class BedrockUsageDetails(BaseModel):
|
||||
total: BedrockUsageDetailsTotal = BedrockUsageDetailsTotal()
|
||||
|
||||
|
||||
class BedrockUsageEvent(BaseModel):
|
||||
totalInputTokens: int = 0
|
||||
totalOutputTokens: int = 0
|
||||
totalTokens: int = 0
|
||||
details: BedrockUsageDetails = BedrockUsageDetails()
|
||||
|
||||
|
||||
TRIGGER_AUDIO_SAMPLE_RATE_HERTZ: Final = 16000
|
||||
TRIGGER_AUDIO_BYTES_PER_SECOND: Final = TRIGGER_AUDIO_SAMPLE_RATE_HERTZ * 2
|
||||
TRIGGER_LEADING_SILENCE: Final = bytes(TRIGGER_AUDIO_BYTES_PER_SECOND // 2)
|
||||
|
|
@ -87,6 +112,15 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
# Text configuration
|
||||
self.text_media_type = "text/plain"
|
||||
|
||||
# Response-stream state (Bedrock events carry no role on textOutput,
|
||||
# so the USER/ASSISTANT split from contentStart is tracked here)
|
||||
self._user_transcript_active = False
|
||||
self._user_transcript_generation_stage: str | None = None
|
||||
self._user_item_id: str | None = None
|
||||
self._user_transcript_buffer = ""
|
||||
self._cumulative_usage = BedrockUsageEvent()
|
||||
self._reported_usage = BedrockUsageEvent()
|
||||
|
||||
def validate_environment(self, headers: dict, model: str, api_key: str | None = None) -> dict:
|
||||
"""Validate environment - no special validation needed for Bedrock."""
|
||||
return headers
|
||||
|
|
@ -691,6 +725,11 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
role: Final = content_start.get("role")
|
||||
|
||||
if role != "ASSISTANT":
|
||||
if role == "USER" and content_start.get("type") == "TEXT":
|
||||
self._user_transcript_active = True
|
||||
self._user_transcript_generation_stage = self._parse_generation_stage(
|
||||
content_start.get("additionalModelFields")
|
||||
)
|
||||
return (
|
||||
[],
|
||||
current_response_id,
|
||||
|
|
@ -700,6 +739,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
)
|
||||
|
||||
verbose_logger.debug("Handling ASSISTANT contentStart")
|
||||
is_new_response: Final = current_response_id is None
|
||||
|
||||
# Initialize IDs if needed
|
||||
if not current_response_id:
|
||||
|
|
@ -715,7 +755,8 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
|
||||
returned_messages: Final[list[OpenAIRealtimeEvents]] = []
|
||||
|
||||
# Send response.created
|
||||
# Send response.created only once per response (a response can contain
|
||||
# multiple content blocks, e.g. TEXT then AUDIO)
|
||||
response_created: Final = OpenAIRealtimeStreamResponseBaseObject(
|
||||
type="response.created",
|
||||
event_id=f"event_{uuid.uuid4()}",
|
||||
|
|
@ -727,7 +768,8 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
"conversation_id": current_conversation_id,
|
||||
},
|
||||
)
|
||||
returned_messages.append(response_created)
|
||||
if is_new_response:
|
||||
returned_messages.append(response_created)
|
||||
|
||||
# Send response.output_item.added
|
||||
output_item_added: Final = OpenAIRealtimeStreamResponseOutputItemAdded(
|
||||
|
|
@ -767,6 +809,108 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
current_delta_type,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_generation_stage(additional_model_fields: object) -> str | None:
|
||||
if not isinstance(additional_model_fields, str):
|
||||
return None
|
||||
try:
|
||||
parsed: Final = json.loads(additional_model_fields)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
stage: Final = parsed.get("generationStage") if isinstance(parsed, dict) else None
|
||||
return stage if isinstance(stage, str) else None
|
||||
|
||||
def _current_user_item_id(self, new_utterance: bool = False) -> str:
|
||||
"""Item id shared by all events of one user utterance (speech boundaries and transcript)."""
|
||||
if new_utterance or self._user_item_id is None:
|
||||
self._user_item_id = f"item_{uuid.uuid4()}"
|
||||
return self._user_item_id
|
||||
|
||||
def transform_user_speech_event(self, is_speech_start: bool) -> tuple[OpenAIRealtimeEvents, ...]:
|
||||
"""Transform Bedrock userSpeechStart/userSpeechEnd to OpenAI speech boundary events."""
|
||||
verbose_logger.debug("Handling userSpeech%s", "Start" if is_speech_start else "End")
|
||||
speech_event: Final[OpenAIRealtimeInputAudioBufferSpeechEvent] = {
|
||||
"type": "input_audio_buffer.speech_started" if is_speech_start else "input_audio_buffer.speech_stopped",
|
||||
"event_id": f"event_{uuid.uuid4()}",
|
||||
"item_id": self._current_user_item_id(new_utterance=is_speech_start),
|
||||
}
|
||||
return (speech_event,)
|
||||
|
||||
def transform_usage_event(self, usage_event: BedrockUsageEvent) -> None:
|
||||
"""Record Bedrock's session-cumulative usage totals for the next response.done."""
|
||||
verbose_logger.debug("Handling usageEvent")
|
||||
self._cumulative_usage = usage_event
|
||||
|
||||
def _take_usage_delta(self) -> OpenAIRealtimeResponseUsage:
|
||||
"""Usage for the response now completing: cumulative totals minus what prior response.done events reported."""
|
||||
prior: Final = self._reported_usage
|
||||
latest: Final = self._cumulative_usage
|
||||
self._reported_usage = latest
|
||||
input_details: Final[OpenAIRealtimeUsageTokenDetails] = {
|
||||
"audio_tokens": latest.details.total.input.speechTokens - prior.details.total.input.speechTokens,
|
||||
"text_tokens": latest.details.total.input.textTokens - prior.details.total.input.textTokens,
|
||||
"cached_tokens": 0,
|
||||
}
|
||||
output_details: Final[OpenAIRealtimeUsageTokenDetails] = {
|
||||
"audio_tokens": latest.details.total.output.speechTokens - prior.details.total.output.speechTokens,
|
||||
"text_tokens": latest.details.total.output.textTokens - prior.details.total.output.textTokens,
|
||||
}
|
||||
usage_delta: Final[OpenAIRealtimeResponseUsage] = {
|
||||
"input_tokens": latest.totalInputTokens - prior.totalInputTokens,
|
||||
"output_tokens": latest.totalOutputTokens - prior.totalOutputTokens,
|
||||
"total_tokens": latest.totalTokens - prior.totalTokens,
|
||||
"input_token_details": input_details,
|
||||
"output_token_details": output_details,
|
||||
}
|
||||
return usage_delta
|
||||
|
||||
def leftover_usage_done_events(self) -> tuple[OpenAIRealtimeEvents, ...]:
|
||||
"""Logged-only response.done for usage Bedrock reports after the final turn's contentEnd."""
|
||||
if self._cumulative_usage == self._reported_usage:
|
||||
return ()
|
||||
usage: Final = self._take_usage_delta()
|
||||
leftover_done: Final = OpenAIRealtimeDoneEvent(
|
||||
type="response.done",
|
||||
event_id=f"event_{uuid.uuid4()}",
|
||||
response=OpenAIRealtimeResponseDoneObject(
|
||||
object="realtime.response",
|
||||
id=f"resp_{uuid.uuid4()}",
|
||||
status="completed",
|
||||
conversation_id=f"conv_{uuid.uuid4()}",
|
||||
usage=dict(usage), # mutable-ok: OpenAIRealtimeResponseDoneObject types usage as plain dict
|
||||
),
|
||||
)
|
||||
return (leftover_done,)
|
||||
|
||||
def transform_user_transcript_event(self, transcript: str) -> tuple[OpenAIRealtimeEvents, ...]:
|
||||
"""Transform a USER-role Bedrock textOutput (ASR transcript) to an OpenAI transcription delta."""
|
||||
verbose_logger.debug("Handling USER textOutput (ASR transcript)")
|
||||
delta_event: Final[OpenAIRealtimeInputAudioTranscriptionDelta] = {
|
||||
"type": "conversation.item.input_audio_transcription.delta",
|
||||
"event_id": f"event_{uuid.uuid4()}",
|
||||
"item_id": self._current_user_item_id(),
|
||||
"content_index": 0,
|
||||
"delta": transcript,
|
||||
}
|
||||
if self._user_transcript_generation_stage != "SPECULATIVE":
|
||||
self._user_transcript_buffer += transcript
|
||||
return (delta_event,)
|
||||
|
||||
def user_transcript_completed_events(self) -> tuple[OpenAIRealtimeEvents, ...]:
|
||||
"""One completed event with the full transcript once the FINAL user content block ends."""
|
||||
transcript: Final = self._user_transcript_buffer
|
||||
if not transcript:
|
||||
return ()
|
||||
self._user_transcript_buffer = ""
|
||||
completed_event: Final[OpenAIRealtimeInputAudioTranscriptionCompleted] = {
|
||||
"type": "conversation.item.input_audio_transcription.completed",
|
||||
"event_id": f"event_{uuid.uuid4()}",
|
||||
"item_id": self._current_user_item_id(),
|
||||
"content_index": 0,
|
||||
"transcript": transcript,
|
||||
}
|
||||
return (completed_event,)
|
||||
|
||||
def transform_text_output_event(
|
||||
self,
|
||||
event: dict,
|
||||
|
|
@ -985,7 +1129,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
if not current_response_id or not current_conversation_id:
|
||||
return [], None, None, None
|
||||
|
||||
usage_obj: Final = get_empty_usage()
|
||||
usage: Final = self._take_usage_delta()
|
||||
response_done: Final = OpenAIRealtimeDoneEvent(
|
||||
type="response.done",
|
||||
event_id=f"event_{uuid.uuid4()}",
|
||||
|
|
@ -995,11 +1139,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
status="completed",
|
||||
output=[],
|
||||
conversation_id=current_conversation_id,
|
||||
usage={
|
||||
"prompt_tokens": usage_obj.prompt_tokens,
|
||||
"completion_tokens": usage_obj.completion_tokens,
|
||||
"total_tokens": usage_obj.total_tokens,
|
||||
},
|
||||
usage=dict(usage),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1042,8 +1182,6 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
|
||||
# Create a function call arguments done event
|
||||
# This is a custom event format that matches what clients expect
|
||||
from typing import cast
|
||||
|
||||
function_call_event: Final[dict[str, Any]] = {
|
||||
"type": "response.function_call_arguments.done",
|
||||
"event_id": f"event_{uuid.uuid4()}",
|
||||
|
|
@ -1194,18 +1332,26 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
returned_messages.extend(events)
|
||||
|
||||
elif "textOutput" in event:
|
||||
events, current_delta_chunks = self.transform_text_output_event(
|
||||
event,
|
||||
current_output_item_id,
|
||||
current_response_id,
|
||||
current_delta_chunks,
|
||||
)
|
||||
returned_messages.extend(events)
|
||||
if self._user_transcript_active:
|
||||
returned_messages.extend(self.transform_user_transcript_event(event["textOutput"].get("content", "")))
|
||||
else:
|
||||
events, current_delta_chunks = self.transform_text_output_event(
|
||||
event,
|
||||
current_output_item_id,
|
||||
current_response_id,
|
||||
current_delta_chunks,
|
||||
)
|
||||
returned_messages.extend(events)
|
||||
|
||||
elif "audioOutput" in event:
|
||||
events = self.transform_audio_output_event(event, current_output_item_id, current_response_id)
|
||||
returned_messages.extend(events)
|
||||
|
||||
elif "contentEnd" in event and self._user_transcript_active:
|
||||
self._user_transcript_active = False
|
||||
self._user_transcript_generation_stage = None
|
||||
returned_messages.extend(self.user_transcript_completed_events())
|
||||
|
||||
elif "contentEnd" in event:
|
||||
events, current_delta_chunks = self.transform_content_end_event(
|
||||
event,
|
||||
|
|
@ -1224,6 +1370,12 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
) = self._response_done_events(current_response_id, current_conversation_id)
|
||||
returned_messages.extend(done_events)
|
||||
|
||||
elif "userSpeechStart" in event or "userSpeechEnd" in event:
|
||||
returned_messages.extend(self.transform_user_speech_event("userSpeechStart" in event))
|
||||
|
||||
elif "usageEvent" in event:
|
||||
self.transform_usage_event(BedrockUsageEvent.model_validate(event["usageEvent"]))
|
||||
|
||||
elif "toolUse" in event:
|
||||
events, tool_call_id, tool_name = self.transform_tool_use_event(
|
||||
event, current_output_item_id, current_response_id
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -9,7 +9,7 @@ response parsing, and streaming chunk parsing for models served with
|
|||
import datetime
|
||||
import json
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from typing import Any, Final
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue, TypeAdapter, ValidationError
|
||||
|
|
@ -76,7 +76,7 @@ def _content_text(content: str | Iterable[Mapping[str, object]] | None) -> str:
|
|||
return str(content)
|
||||
|
||||
|
||||
def _extract_text_content(content: Any) -> str:
|
||||
def _extract_text_content(content: str | Iterable[Mapping[str, object]] | None) -> str:
|
||||
"""Return the plain-text representation of a message content value."""
|
||||
return _content_text(content)
|
||||
|
||||
|
|
|
|||
|
|
@ -160,14 +160,12 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
|||
**self._prompt_image_param(video_create_optional_params),
|
||||
**self._ratio_param(video_create_optional_params),
|
||||
**self._duration_param(video_create_optional_params),
|
||||
# Pass through other parameters that aren't OpenAI-specific
|
||||
**{key: value for key, value in video_create_optional_params.items() if key not in supported_openai_params},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _prompt_image_param(video_create_optional_params: VideoCreateOptionalRequestParams) -> Mapping[str, object]:
|
||||
# Handle input_reference parameter - map to promptImage
|
||||
# RunwayML supports URLs and data URIs directly
|
||||
if "input_reference" in video_create_optional_params:
|
||||
return {"promptImage": video_create_optional_params["input_reference"]}
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,6 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM):
|
|||
else None
|
||||
)
|
||||
|
||||
# Generation config with proper structure for image editing
|
||||
generation_config: Final[dict[str, object]] = {
|
||||
key: value for key, value in (("response_modalities", ["IMAGE"]), ("image_config", image_config)) if value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,7 +203,6 @@ class VertexVectorStoreConfig(BaseVectorStoreConfig, VertexBase):
|
|||
if value is not None
|
||||
}
|
||||
|
||||
# Build the request body for Vertex AI RAG API
|
||||
query_body: Final[Mapping[str, object]] = {
|
||||
key: value
|
||||
for key, value in (("text", query), ("rag_retrieval_config", rag_retrieval_config or None))
|
||||
|
|
@ -294,7 +293,6 @@ class VertexVectorStoreConfig(BaseVectorStoreConfig, VertexBase):
|
|||
# Add metadata if provided
|
||||
metadata: Final = vector_store_create_optional_params.get("metadata")
|
||||
|
||||
# Build the request body for Vertex AI RAG Corpus creation
|
||||
request_body: Final[dict[str, object]] = {
|
||||
key: value
|
||||
for key, value in (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -553,6 +553,26 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"amazon.nova-sonic-v1:0": {
|
||||
"input_cost_per_audio_token": 3.4e-06,
|
||||
"input_cost_per_token": 6e-08,
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "realtime",
|
||||
"output_cost_per_audio_token": 1.36e-05,
|
||||
"output_cost_per_token": 2.4e-07,
|
||||
"supports_audio_input": true,
|
||||
"supports_audio_output": true
|
||||
},
|
||||
"amazon.nova-2-sonic-v1:0": {
|
||||
"input_cost_per_audio_token": 3e-06,
|
||||
"input_cost_per_token": 3.3e-07,
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "realtime",
|
||||
"output_cost_per_audio_token": 1.2e-05,
|
||||
"output_cost_per_token": 2.75e-06,
|
||||
"supports_audio_input": true,
|
||||
"supports_audio_output": true
|
||||
},
|
||||
"amazon.rerank-v1:0": {
|
||||
"input_cost_per_query": 0.001,
|
||||
"input_cost_per_token": 0.0,
|
||||
|
|
@ -19566,6 +19586,61 @@
|
|||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"friendliai/zai-org/GLM-5.3-Flash": {
|
||||
"litellm_provider": "friendliai",
|
||||
"supports_reasoning": true,
|
||||
"supports_function_calling": true,
|
||||
"max_input_tokens": 1048576,
|
||||
"max_tokens": 1048576,
|
||||
"max_output_tokens": 1048576,
|
||||
"input_cost_per_token": 1.5e-07,
|
||||
"output_cost_per_token": 5e-07,
|
||||
"cache_read_input_token_cost": 3e-08,
|
||||
"supports_prompt_caching": true,
|
||||
"reasoning_effort_levels": [
|
||||
"low",
|
||||
"high",
|
||||
"max"
|
||||
],
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"mode": "chat",
|
||||
"comment": "Native multimodal GLM model for efficient coding and long-horizon agent tasks",
|
||||
"source": "https://api.friendli.ai/serverless/v1/models",
|
||||
"supports_vision": true,
|
||||
"supports_image_input": true,
|
||||
"supports_video_input": true
|
||||
},
|
||||
"friendliai/zai-org/GLM-5.3": {
|
||||
"litellm_provider": "friendliai",
|
||||
"supports_reasoning": true,
|
||||
"supports_function_calling": true,
|
||||
"max_input_tokens": 1048576,
|
||||
"max_tokens": 1048576,
|
||||
"max_output_tokens": 1048576,
|
||||
"input_cost_per_token": 1.26e-06,
|
||||
"output_cost_per_token": 3.96e-06,
|
||||
"cache_read_input_token_cost": 2.34e-07,
|
||||
"supports_prompt_caching": true,
|
||||
"reasoning_effort_levels": [
|
||||
"low",
|
||||
"high",
|
||||
"max"
|
||||
],
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"mode": "chat",
|
||||
"comment": "Flagship GLM model for long-horizon coding, agents, and complex project delivery",
|
||||
"source": "https://api.friendli.ai/serverless/v1/models",
|
||||
"supports_vision": false,
|
||||
"supports_image_input": false
|
||||
},
|
||||
"ft:babbage-002": {
|
||||
"deprecation_date": "2026-10-23",
|
||||
"input_cost_per_token": 1.6e-06,
|
||||
|
|
@ -24344,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,
|
||||
|
|
@ -24406,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)
|
||||
|
|
|
|||
|
|
@ -433,7 +433,7 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
payload["model"] = model
|
||||
|
||||
try:
|
||||
raw_response: HttpxResponse = await self.async_handler.post( # pyright: ignore[reportUnknownMemberType]
|
||||
raw_response: HttpxResponse = await self.async_handler.post( # pyright: ignore[reportUnknownMemberType] # AsyncHTTPHandler.post is untyped
|
||||
url=f"{self.headroom_api_base}/v1/compress",
|
||||
json=payload,
|
||||
headers=self._request_headers(),
|
||||
|
|
@ -570,7 +570,7 @@ class HeadroomGuardrail(CustomGuardrail):
|
|||
params["query"] = query
|
||||
|
||||
try:
|
||||
raw_response: HttpxResponse = await self.async_handler.get( # pyright: ignore[reportUnknownMemberType]
|
||||
raw_response: HttpxResponse = await self.async_handler.get( # pyright: ignore[reportUnknownMemberType] # AsyncHTTPHandler.get is untyped
|
||||
url=f"{self.headroom_api_base}/v1/retrieve/{hash_value}",
|
||||
params=params,
|
||||
headers=self._request_headers(),
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ class RepelloAIGuardrail(CustomGuardrail):
|
|||
repelloai_response: RepelloAIAnalyzeResponse | None = None
|
||||
try:
|
||||
verbose_proxy_logger.debug("RepelloAI Argus request: %s", request)
|
||||
response: Final[HttpxResponse] = await self.async_handler.post( # pyright: ignore[reportUnknownMemberType]
|
||||
response: Final[HttpxResponse] = await self.async_handler.post( # pyright: ignore[reportUnknownMemberType] # AsyncHTTPHandler.post is untyped
|
||||
url=endpoint,
|
||||
headers={"X-API-Key": self.repelloai_api_key},
|
||||
json=request,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -4492,27 +4495,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,
|
||||
|
|
@ -71,13 +75,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()
|
||||
|
||||
|
||||
|
|
@ -510,8 +518,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(
|
||||
|
|
@ -1288,7 +1302,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:
|
||||
|
|
@ -1717,7 +1731,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()
|
||||
|
|
@ -1741,7 +1755,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
|
||||
|
|
@ -1906,12 +1920,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
|
||||
|
|
@ -2151,8 +2165,6 @@ async def vertex_discovery_proxy_route(
|
|||
"""
|
||||
import re
|
||||
|
||||
from litellm.types.vector_stores import LiteLLM_ManagedVectorStore
|
||||
|
||||
# Extract vector store ID from endpoint if present (e.g., dataStores/test-litellm-app_1761094730750)
|
||||
vector_store_credentials: LiteLLM_ManagedVectorStore | None = None
|
||||
vector_store_id_match: Final = re.search(r"dataStores/([^/]+)", endpoint)
|
||||
|
|
@ -2559,7 +2571,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
|
||||
|
|
@ -2599,7 +2611,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.
|
||||
|
|
@ -2619,7 +2631,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
|
||||
"""
|
||||
|
|
@ -2809,6 +2821,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",
|
||||
|
|
|
|||
|
|
@ -144,10 +144,8 @@ async def background_streaming_task(
|
|||
|
||||
# Process streaming response following OpenAI events format
|
||||
# https://platform.openai.com/docs/api-reference/responses-streaming
|
||||
output_items: Final = dict[str, _OutputItem]() # Track output items by ID
|
||||
accumulated_text: Final = dict[
|
||||
tuple[str, int], str
|
||||
]() # Track accumulated text deltas by (item_id, content_index)
|
||||
output_items: Final = dict[str, _OutputItem]()
|
||||
accumulated_text: Final = dict[tuple[str, int], str]()
|
||||
|
||||
# ResponsesAPIResponse fields to extract from response.completed
|
||||
usage_data = None
|
||||
|
|
@ -262,7 +260,6 @@ async def background_streaming_task(
|
|||
if "content" in delta_item:
|
||||
content_list = delta_item["content"]
|
||||
if content_index < len(content_list):
|
||||
# Update existing content part with accumulated text
|
||||
content_entry = content_list[content_index]
|
||||
if isinstance(content_entry, dict):
|
||||
content_entry["text"] = accumulated_text[key]
|
||||
|
|
|
|||
|
|
@ -186,7 +186,6 @@ class VertexAIRAGIngestion(BaseRAGIngestion, VertexBase):
|
|||
base_url: Final = get_vertex_base_url(self.location)
|
||||
url: Final = f"{base_url}/v1beta1/projects/{self.project_id}/locations/{self.location}/ragCorpora"
|
||||
|
||||
# Build request body with camelCase keys (Vertex AI API format)
|
||||
vector_db_config: Final = self.vector_store_config.get("vector_db_config")
|
||||
embedding_model: Final = self.vector_store_config.get("embedding_model")
|
||||
embedding_model_config: Final = (
|
||||
|
|
@ -447,7 +446,6 @@ class VertexAIRAGIngestion(BaseRAGIngestion, VertexBase):
|
|||
# Add max embedding requests per minute if specified
|
||||
max_embedding_qpm: Final = self.vector_store_config.get("max_embedding_requests_per_min")
|
||||
|
||||
# Build request body with camelCase keys (Vertex AI API format)
|
||||
chunking_config: Final = (
|
||||
{"chunkSize": chunk_size or 1024, "chunkOverlap": chunk_overlap or 200}
|
||||
if chunk_size or chunk_overlap
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -9152,7 +9154,8 @@ class Router:
|
|||
if _deployment_on_router is not None:
|
||||
# deployment with this model_id exists on the router
|
||||
if (
|
||||
deployment.litellm_params == _deployment_on_router.litellm_params
|
||||
deployment.model_name == _deployment_on_router.model_name
|
||||
and deployment.litellm_params == _deployment_on_router.litellm_params
|
||||
and deployment.model_info == _deployment_on_router.model_info
|
||||
):
|
||||
# No need to update
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2162,6 +2162,42 @@ class OpenAIRealtimeDoneEvent(TypedDict):
|
|||
type: Literal["response.done"]
|
||||
|
||||
|
||||
class OpenAIRealtimeInputAudioBufferSpeechEvent(TypedDict):
|
||||
type: ReadOnly[Literal["input_audio_buffer.speech_started", "input_audio_buffer.speech_stopped"]]
|
||||
event_id: ReadOnly[str]
|
||||
item_id: ReadOnly[str]
|
||||
|
||||
|
||||
class OpenAIRealtimeInputAudioTranscriptionDelta(TypedDict):
|
||||
type: ReadOnly[Literal["conversation.item.input_audio_transcription.delta"]]
|
||||
event_id: ReadOnly[str]
|
||||
item_id: ReadOnly[str]
|
||||
content_index: ReadOnly[int]
|
||||
delta: ReadOnly[str]
|
||||
|
||||
|
||||
class OpenAIRealtimeInputAudioTranscriptionCompleted(TypedDict):
|
||||
type: ReadOnly[Literal["conversation.item.input_audio_transcription.completed"]]
|
||||
event_id: ReadOnly[str]
|
||||
item_id: ReadOnly[str]
|
||||
content_index: ReadOnly[int]
|
||||
transcript: ReadOnly[str]
|
||||
|
||||
|
||||
class OpenAIRealtimeUsageTokenDetails(TypedDict):
|
||||
audio_tokens: ReadOnly[int]
|
||||
text_tokens: ReadOnly[int]
|
||||
cached_tokens: NotRequired[ReadOnly[int]]
|
||||
|
||||
|
||||
class OpenAIRealtimeResponseUsage(TypedDict):
|
||||
input_tokens: ReadOnly[int]
|
||||
output_tokens: ReadOnly[int]
|
||||
total_tokens: ReadOnly[int]
|
||||
input_token_details: NotRequired[ReadOnly[OpenAIRealtimeUsageTokenDetails]]
|
||||
output_token_details: NotRequired[ReadOnly[OpenAIRealtimeUsageTokenDetails]]
|
||||
|
||||
|
||||
class OpenAIRealtimeEventTypes(Enum):
|
||||
SESSION_CREATED = "session.created"
|
||||
# Beta delta event names
|
||||
|
|
@ -2199,6 +2235,9 @@ OpenAIRealtimeEvents = (
|
|||
| OpenAIRealtimeOutputItemDone
|
||||
| OpenAIRealtimeFunctionCallArgumentsDone
|
||||
| OpenAIRealtimeDoneEvent
|
||||
| OpenAIRealtimeInputAudioBufferSpeechEvent
|
||||
| OpenAIRealtimeInputAudioTranscriptionDelta
|
||||
| OpenAIRealtimeInputAudioTranscriptionCompleted
|
||||
)
|
||||
|
||||
OpenAIRealtimeStreamList = list[OpenAIRealtimeEvents]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -553,6 +553,26 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"amazon.nova-sonic-v1:0": {
|
||||
"input_cost_per_audio_token": 3.4e-06,
|
||||
"input_cost_per_token": 6e-08,
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "realtime",
|
||||
"output_cost_per_audio_token": 1.36e-05,
|
||||
"output_cost_per_token": 2.4e-07,
|
||||
"supports_audio_input": true,
|
||||
"supports_audio_output": true
|
||||
},
|
||||
"amazon.nova-2-sonic-v1:0": {
|
||||
"input_cost_per_audio_token": 3e-06,
|
||||
"input_cost_per_token": 3.3e-07,
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "realtime",
|
||||
"output_cost_per_audio_token": 1.2e-05,
|
||||
"output_cost_per_token": 2.75e-06,
|
||||
"supports_audio_input": true,
|
||||
"supports_audio_output": true
|
||||
},
|
||||
"amazon.rerank-v1:0": {
|
||||
"input_cost_per_query": 0.001,
|
||||
"input_cost_per_token": 0.0,
|
||||
|
|
@ -19566,6 +19586,61 @@
|
|||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"friendliai/zai-org/GLM-5.3-Flash": {
|
||||
"litellm_provider": "friendliai",
|
||||
"supports_reasoning": true,
|
||||
"supports_function_calling": true,
|
||||
"max_input_tokens": 1048576,
|
||||
"max_tokens": 1048576,
|
||||
"max_output_tokens": 1048576,
|
||||
"input_cost_per_token": 1.5e-07,
|
||||
"output_cost_per_token": 5e-07,
|
||||
"cache_read_input_token_cost": 3e-08,
|
||||
"supports_prompt_caching": true,
|
||||
"reasoning_effort_levels": [
|
||||
"low",
|
||||
"high",
|
||||
"max"
|
||||
],
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"mode": "chat",
|
||||
"comment": "Native multimodal GLM model for efficient coding and long-horizon agent tasks",
|
||||
"source": "https://api.friendli.ai/serverless/v1/models",
|
||||
"supports_vision": true,
|
||||
"supports_image_input": true,
|
||||
"supports_video_input": true
|
||||
},
|
||||
"friendliai/zai-org/GLM-5.3": {
|
||||
"litellm_provider": "friendliai",
|
||||
"supports_reasoning": true,
|
||||
"supports_function_calling": true,
|
||||
"max_input_tokens": 1048576,
|
||||
"max_tokens": 1048576,
|
||||
"max_output_tokens": 1048576,
|
||||
"input_cost_per_token": 1.26e-06,
|
||||
"output_cost_per_token": 3.96e-06,
|
||||
"cache_read_input_token_cost": 2.34e-07,
|
||||
"supports_prompt_caching": true,
|
||||
"reasoning_effort_levels": [
|
||||
"low",
|
||||
"high",
|
||||
"max"
|
||||
],
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"mode": "chat",
|
||||
"comment": "Flagship GLM model for long-horizon coding, agents, and complex project delivery",
|
||||
"source": "https://api.friendli.ai/serverless/v1/models",
|
||||
"supports_vision": false,
|
||||
"supports_image_input": false
|
||||
},
|
||||
"ft:babbage-002": {
|
||||
"deprecation_date": "2026-10-23",
|
||||
"input_cost_per_token": 1.6e-06,
|
||||
|
|
@ -24344,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,
|
||||
|
|
@ -24406,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",
|
||||
|
|
|
|||
|
|
@ -2,3 +2,8 @@
|
|||
id = "GHSA-w8v5-vhqr-4h9v"
|
||||
ignoreUntil = 2026-09-09
|
||||
reason = "diskcache has no fixed release published; remove this entry once one exists"
|
||||
|
||||
[[IgnoredVulns]]
|
||||
id = "GHSA-h7x2-h6g9-p789"
|
||||
ignoreUntil = 2026-09-14
|
||||
reason = "mlflow has no fixed release published; remove this entry once one exists"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
"user": "",
|
||||
"team_id": "",
|
||||
"organization_id": "",
|
||||
"metadata": "{\"applied_guardrails\": [], \"attempted_fallbacks\": null, \"original_model_group\": null, \"batch_models\": null, \"batch_successful_requests\": null, \"batch_failed_requests\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"routing_decision\": null, \"internal_call_origin\": null, \"guardrail_information\": null, \"compression_savings\": null, \"litellm_gateway_injected_cache\": null, \"router_metadata\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_user_id\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null}",
|
||||
"metadata": "{\"applied_guardrails\": [], \"attempted_fallbacks\": null, \"original_model_group\": null, \"batch_models\": null, \"batch_successful_requests\": null, \"batch_failed_requests\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"routing_decision\": null, \"internal_call_origin\": null, \"router_metadata\": null, \"guardrail_information\": null, \"compression_savings\": null, \"litellm_gateway_injected_cache\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_user_id\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null}",
|
||||
"cache_key": "Cache OFF",
|
||||
"spend": 0.00022500000000000002,
|
||||
"total_tokens": 30,
|
||||
|
|
|
|||
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]
|
||||
|
|
|
|||
|
|
@ -1490,6 +1490,92 @@ class MockCanaryMaskingGuardrail(CustomGuardrail):
|
|||
return inputs
|
||||
|
||||
|
||||
class TestAnthropicMessagesImageSources:
|
||||
"""An Anthropic image block has three source shapes (`AnthropicMessagesImageParam.source`).
|
||||
|
||||
Only the base64 one carries "data", so reading that key alone drops url images
|
||||
entirely -- for every guardrail consuming GenericGuardrailAPIInputs["images"],
|
||||
not just Bedrock.
|
||||
"""
|
||||
|
||||
def _data(self, messages):
|
||||
return {"model": "claude-sonnet-4-5", "messages": messages}
|
||||
|
||||
async def _images_seen(self, content) -> list[str]:
|
||||
handler = AnthropicMessagesHandler()
|
||||
|
||||
class ImageRecordingGuardrail(MockCanaryMaskingGuardrail):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.seen_images: list[str] = [] # mutable-ok: accumulator for the assertion
|
||||
|
||||
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
|
||||
self.seen_images.extend(inputs.get("images") or [])
|
||||
return await super().apply_guardrail(inputs, request_data, input_type, logging_obj)
|
||||
|
||||
guardrail = ImageRecordingGuardrail()
|
||||
# The text block is what gets the guardrail invoked at all: a message with
|
||||
# no text gives the handler nothing to scan, so it never reaches the
|
||||
# guardrail and every source shape would look equally "dropped".
|
||||
await handler.process_input_messages(
|
||||
data=self._data([{"role": "user", "content": [{"type": "text", "text": "describe it"}, *content]}]),
|
||||
guardrail_to_apply=guardrail,
|
||||
)
|
||||
return guardrail.seen_images
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_url_source_reaches_the_guardrail(self):
|
||||
"""A url source has no "data" key, so it used to yield nothing at all."""
|
||||
seen = await self._images_seen(
|
||||
[{"type": "image", "source": {"type": "url", "url": "https://example.com/a.png"}}]
|
||||
)
|
||||
|
||||
assert seen == ["https://example.com/a.png"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base64_source_carries_its_media_type(self):
|
||||
"""Bare base64 leaves the consumer no way to recover the format.
|
||||
|
||||
An API like Bedrock's ApplyGuardrail needs it to build the request, so the
|
||||
media_type travels with the payload as a data URI.
|
||||
"""
|
||||
seen = await self._images_seen(
|
||||
[{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AAAA"}}]
|
||||
)
|
||||
|
||||
assert seen == ["data:image/png;base64,AAAA"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base64_source_without_a_media_type_is_passed_through(self):
|
||||
"""There is no format to attach, so the payload goes through unchanged."""
|
||||
seen = await self._images_seen([{"type": "image", "source": {"type": "base64", "data": "AAAA"}}])
|
||||
|
||||
assert seen == ["AAAA"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_source_yields_nothing(self):
|
||||
"""The bytes live behind the Files API and this extractor has no client.
|
||||
|
||||
Documented as a known gap rather than silently handed on as a file_id string,
|
||||
which a consumer would try to decode as an image.
|
||||
"""
|
||||
seen = await self._images_seen([{"type": "image", "source": {"type": "file", "file_id": "file_abc"}}])
|
||||
|
||||
assert seen == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_malformed_source_is_dropped_rather_than_passed_on(self):
|
||||
seen = await self._images_seen(
|
||||
[
|
||||
{"type": "image", "source": {"type": "base64"}},
|
||||
{"type": "image", "source": {"type": "url"}},
|
||||
{"type": "image", "source": {"type": "base64", "data": ""}},
|
||||
]
|
||||
)
|
||||
|
||||
assert seen == []
|
||||
|
||||
|
||||
class TestAnthropicMessagesToolResultScanning:
|
||||
"""LIT-5251: tool_result blocks carry whatever a client's local tool fetched, so
|
||||
they are the request-path payload an indirect prompt injection actually arrives in.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from unittest.mock import MagicMock
|
|||
|
||||
import pytest
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.llms.bedrock.realtime.handler import BedrockRealtime
|
||||
from litellm.llms.bedrock.realtime.transformation import BedrockRealtimeConfig
|
||||
|
|
@ -104,12 +104,24 @@ class RealtimeClientWS:
|
|||
self.closed = True
|
||||
|
||||
|
||||
class ImmediatelyEndingBedrockStream:
|
||||
def __init__(self):
|
||||
class ScriptedBedrockReceiver:
|
||||
def __init__(self, payloads):
|
||||
self._payloads = list(payloads)
|
||||
|
||||
async def receive(self):
|
||||
if not self._payloads:
|
||||
return None
|
||||
payload = self._payloads.pop(0)
|
||||
return SimpleNamespace(value=SimpleNamespace(bytes_=payload.encode("utf-8")))
|
||||
|
||||
|
||||
class ScriptedBedrockStream:
|
||||
def __init__(self, payloads):
|
||||
self.input_stream = FakeInputStream()
|
||||
self._receiver = ScriptedBedrockReceiver(payloads)
|
||||
|
||||
async def await_output(self):
|
||||
return (None, EndedBedrockReceiver())
|
||||
return (None, self._receiver)
|
||||
|
||||
|
||||
class FakeStaticCredentialsResolver:
|
||||
|
|
@ -151,7 +163,7 @@ def stub_aws_sdk_client(monkeypatch):
|
|||
|
||||
async def invoke_model_with_bidirectional_stream(self, operation_input):
|
||||
captured["operation_input"] = operation_input
|
||||
return ImmediatelyEndingBedrockStream()
|
||||
return ScriptedBedrockStream(captured.get("scripted_payloads", []))
|
||||
|
||||
package = types.ModuleType("aws_sdk_bedrock_runtime")
|
||||
client_module = types.ModuleType("aws_sdk_bedrock_runtime.client")
|
||||
|
|
@ -271,19 +283,132 @@ class TestBedrockRealtimeHandler:
|
|||
assert "sessionEnd" in event_names
|
||||
assert stream.input_stream.closed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forwarded_events_are_filtered_to_logged_types_for_spend_logging(self):
|
||||
handler = BedrockRealtime()
|
||||
stream = ScriptedBedrockStream(
|
||||
[
|
||||
json.dumps({"event": {"userSpeechStart": {}}}),
|
||||
json.dumps({"event": {"contentStart": {"role": "ASSISTANT", "type": "TEXT"}}}),
|
||||
json.dumps({"event": {"textOutput": {"content": "Hi"}}}),
|
||||
json.dumps({"event": {"contentEnd": {"stopReason": "END_TURN"}}}),
|
||||
]
|
||||
)
|
||||
client_ws = RealtimeClientWS()
|
||||
|
||||
logged_events = [
|
||||
event
|
||||
async for event in handler._forward_bedrock_to_client(
|
||||
stream,
|
||||
client_ws,
|
||||
BedrockRealtimeConfig(),
|
||||
"amazon.nova-sonic-v1:0",
|
||||
FakeLogging(),
|
||||
{},
|
||||
)
|
||||
]
|
||||
|
||||
assert [event["type"] for event in logged_events] == ["response.done"]
|
||||
sent_types = [json.loads(message)["type"] for message in client_ws.sent_to_client]
|
||||
assert "input_audio_buffer.speech_started" in sent_types
|
||||
assert "response.text.delta" in sent_types
|
||||
assert "response.done" in sent_types
|
||||
assert client_ws.closed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logged_event_types_star_collects_every_forwarded_event(self, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "logged_real_time_event_types", "*")
|
||||
handler = BedrockRealtime()
|
||||
stream = ScriptedBedrockStream(
|
||||
[
|
||||
json.dumps({"event": {"userSpeechStart": {}}}),
|
||||
json.dumps({"event": {"userSpeechEnd": {}}}),
|
||||
]
|
||||
)
|
||||
client_ws = RealtimeClientWS()
|
||||
|
||||
logged_events = [
|
||||
event
|
||||
async for event in handler._forward_bedrock_to_client(
|
||||
stream,
|
||||
client_ws,
|
||||
BedrockRealtimeConfig(),
|
||||
"amazon.nova-sonic-v1:0",
|
||||
FakeLogging(),
|
||||
{},
|
||||
)
|
||||
]
|
||||
|
||||
assert [event["type"] for event in logged_events] == [
|
||||
"input_audio_buffer.speech_started",
|
||||
"input_audio_buffer.speech_stopped",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trailing_usage_after_last_done_is_dispatched_for_spend(self, stub_aws_sdk_client, monkeypatch):
|
||||
import litellm.llms.bedrock.realtime.handler as handler_module
|
||||
|
||||
dispatched = {}
|
||||
|
||||
class RecordingLogging(FakeLogging):
|
||||
async def dispatch_success_handlers(self, result=None, prefer_async_handlers=False, **kwargs):
|
||||
dispatched["events"] = result
|
||||
|
||||
class RecordingLoggingWorker:
|
||||
def ensure_initialized_and_enqueue(self, coro):
|
||||
dispatched["coro"] = coro
|
||||
|
||||
monkeypatch.setattr(handler_module, "GLOBAL_LOGGING_WORKER", RecordingLoggingWorker())
|
||||
stub_aws_sdk_client["scripted_payloads"] = [
|
||||
json.dumps(
|
||||
{
|
||||
"event": {
|
||||
"usageEvent": {
|
||||
"totalInputTokens": 3,
|
||||
"totalOutputTokens": 6,
|
||||
"totalTokens": 9,
|
||||
"details": {
|
||||
"total": {
|
||||
"input": {"speechTokens": 3, "textTokens": 0},
|
||||
"output": {"speechTokens": 0, "textTokens": 6},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
await BedrockRealtime().async_realtime(
|
||||
model="amazon.nova-sonic-v1:0",
|
||||
websocket=RealtimeClientWS(),
|
||||
logging_obj=RecordingLogging(),
|
||||
aws_region_name="us-east-1",
|
||||
aws_access_key_id="k",
|
||||
aws_secret_access_key="s",
|
||||
)
|
||||
await dispatched["coro"]
|
||||
|
||||
assert [event["type"] for event in dispatched["events"]] == ["response.done"]
|
||||
usage = dispatched["events"][0]["response"]["usage"]
|
||||
assert (usage["input_tokens"], usage["output_tokens"], usage["total_tokens"]) == (3, 6, 9)
|
||||
assert usage["input_token_details"] == {"audio_tokens": 3, "text_tokens": 0, "cached_tokens": 0}
|
||||
assert usage["output_token_details"] == {"audio_tokens": 0, "text_tokens": 6}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bedrock_stream_end_closes_client_websocket(self):
|
||||
handler = BedrockRealtime()
|
||||
client_ws = ClosableClientWS()
|
||||
|
||||
await handler._forward_bedrock_to_client(
|
||||
async for _ in handler._forward_bedrock_to_client(
|
||||
EndedBedrockStream(),
|
||||
client_ws,
|
||||
BedrockRealtimeConfig(),
|
||||
"amazon.nova-sonic-v1:0",
|
||||
MagicMock(),
|
||||
{},
|
||||
)
|
||||
):
|
||||
pass
|
||||
|
||||
assert client_ws.closed
|
||||
|
||||
|
|
@ -320,9 +445,7 @@ class TestBedrockRealtimeSessionLifecycle:
|
|||
[json.dumps({"type": "session.update", "session": {"instructions": "hi", "modalities": ["text"]}})]
|
||||
)
|
||||
|
||||
await handler._forward_client_to_bedrock(
|
||||
client_ws, stream, config, "amazon.nova-sonic-v1:0", {}, FakeLogging()
|
||||
)
|
||||
await handler._forward_client_to_bedrock(client_ws, stream, config, "amazon.nova-sonic-v1:0", {}, FakeLogging())
|
||||
|
||||
acked = [json.loads(message) for message in client_ws.sent_to_client]
|
||||
updated = [event for event in acked if event["type"] == "session.updated"]
|
||||
|
|
@ -334,9 +457,7 @@ class TestBedrockRealtimeSessionLifecycle:
|
|||
handler = BedrockRealtime()
|
||||
config = BedrockRealtimeConfig()
|
||||
stream = FakeBedrockStream()
|
||||
client_ws = DisconnectingClientWS(
|
||||
[json.dumps({"type": "session.update", "session": {"instructions": "hi"}})]
|
||||
)
|
||||
client_ws = DisconnectingClientWS([json.dumps({"type": "session.update", "session": {"instructions": "hi"}})])
|
||||
|
||||
await handler._forward_client_to_bedrock(client_ws, stream, config, "amazon.nova-sonic-v1:0", {})
|
||||
|
||||
|
|
|
|||
|
|
@ -827,5 +827,310 @@ class TestBedrockRealtimeSessionEvents:
|
|||
assert event["session"]["modalities"] == ["text", "audio"]
|
||||
|
||||
|
||||
class TestBedrockRealtimeUserEventsAndUsage:
|
||||
"""Regression tests for #38346: USER ASR transcripts, speech boundary events,
|
||||
usage propagation, and duplicate response.created"""
|
||||
|
||||
@staticmethod
|
||||
def _run(config, messages):
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.litellm_trace_id = "trace_123"
|
||||
state = {
|
||||
"session_configuration_request": json.dumps({"configured": True}),
|
||||
"current_output_item_id": None,
|
||||
"current_response_id": None,
|
||||
"current_conversation_id": None,
|
||||
"current_delta_chunks": [],
|
||||
"current_item_chunks": [],
|
||||
"current_delta_type": None,
|
||||
}
|
||||
all_events = []
|
||||
for msg in messages:
|
||||
result = config.transform_realtime_response(
|
||||
json.dumps(msg),
|
||||
"amazon.nova-2-sonic-v1:0",
|
||||
logging_obj,
|
||||
realtime_response_transform_input=dict(state),
|
||||
)
|
||||
all_events.extend(result["response"])
|
||||
state.update(
|
||||
{
|
||||
"current_output_item_id": result["current_output_item_id"],
|
||||
"current_response_id": result["current_response_id"],
|
||||
"current_conversation_id": result["current_conversation_id"],
|
||||
"current_delta_chunks": result["current_delta_chunks"],
|
||||
"current_delta_type": result["current_delta_type"],
|
||||
}
|
||||
)
|
||||
return all_events
|
||||
|
||||
def test_user_speech_start_and_stop_events(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[{"event": {"userSpeechStart": {}}}, {"event": {"userSpeechEnd": {}}}],
|
||||
)
|
||||
assert [e["type"] for e in events] == [
|
||||
"input_audio_buffer.speech_started",
|
||||
"input_audio_buffer.speech_stopped",
|
||||
]
|
||||
assert all(e["event_id"] and e["item_id"] for e in events)
|
||||
assert events[0]["item_id"] == events[1]["item_id"]
|
||||
|
||||
def test_utterance_lifecycle_shares_one_item_id(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[
|
||||
{"event": {"userSpeechStart": {}}},
|
||||
{"event": {"userSpeechEnd": {}}},
|
||||
{
|
||||
"event": {
|
||||
"contentStart": {
|
||||
"role": "USER",
|
||||
"type": "TEXT",
|
||||
"additionalModelFields": json.dumps({"generationStage": "FINAL"}),
|
||||
}
|
||||
}
|
||||
},
|
||||
{"event": {"textOutput": {"content": "ready"}}},
|
||||
{"event": {"contentEnd": {"stopReason": "PARTIAL_TURN"}}},
|
||||
],
|
||||
)
|
||||
item_ids = {e["item_id"] for e in events if "item_id" in e}
|
||||
assert len(item_ids) == 1
|
||||
|
||||
def test_new_utterance_gets_new_item_id(self):
|
||||
config = BedrockRealtimeConfig()
|
||||
first = self._run(config, [{"event": {"userSpeechStart": {}}}, {"event": {"userSpeechEnd": {}}}])
|
||||
second = self._run(config, [{"event": {"userSpeechStart": {}}}, {"event": {"userSpeechEnd": {}}}])
|
||||
assert first[0]["item_id"] == first[1]["item_id"]
|
||||
assert second[0]["item_id"] == second[1]["item_id"]
|
||||
assert first[0]["item_id"] != second[0]["item_id"]
|
||||
|
||||
def test_user_transcript_emits_input_audio_transcription_events(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[
|
||||
{
|
||||
"event": {
|
||||
"contentStart": {
|
||||
"role": "USER",
|
||||
"type": "TEXT",
|
||||
"additionalModelFields": json.dumps({"generationStage": "FINAL"}),
|
||||
}
|
||||
}
|
||||
},
|
||||
{"event": {"textOutput": {"content": "ready"}}},
|
||||
{"event": {"contentEnd": {"stopReason": "PARTIAL_TURN"}}},
|
||||
],
|
||||
)
|
||||
deltas = [e for e in events if e["type"] == "conversation.item.input_audio_transcription.delta"]
|
||||
completed = [e for e in events if e["type"] == "conversation.item.input_audio_transcription.completed"]
|
||||
assert len(deltas) == 1 and deltas[0]["delta"] == "ready"
|
||||
assert len(completed) == 1 and completed[0]["transcript"] == "ready"
|
||||
assert deltas[0]["item_id"] == completed[0]["item_id"]
|
||||
assert not any(e["type"] == "response.text.delta" for e in events)
|
||||
|
||||
def test_speculative_user_transcript_emits_delta_only(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[
|
||||
{
|
||||
"event": {
|
||||
"contentStart": {
|
||||
"role": "USER",
|
||||
"type": "TEXT",
|
||||
"additionalModelFields": json.dumps({"generationStage": "SPECULATIVE"}),
|
||||
}
|
||||
}
|
||||
},
|
||||
{"event": {"textOutput": {"content": "rea"}}},
|
||||
],
|
||||
)
|
||||
assert [e["type"] for e in events] == ["conversation.item.input_audio_transcription.delta"]
|
||||
|
||||
def test_user_transcript_state_resets_on_content_end(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[
|
||||
{"event": {"contentStart": {"role": "USER", "type": "TEXT"}}},
|
||||
{"event": {"contentEnd": {"stopReason": "PARTIAL_TURN"}}},
|
||||
{"event": {"contentStart": {"role": "ASSISTANT", "type": "TEXT"}}},
|
||||
{"event": {"textOutput": {"content": "Hi there"}}},
|
||||
],
|
||||
)
|
||||
text_deltas = [e for e in events if e["type"] == "response.text.delta"]
|
||||
assert len(text_deltas) == 1 and text_deltas[0]["delta"] == "Hi there"
|
||||
assert not any(e["type"].startswith("conversation.item.input_audio_transcription") for e in events)
|
||||
|
||||
def test_response_created_emitted_once_per_response(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[
|
||||
{"event": {"contentStart": {"role": "ASSISTANT", "type": "TEXT"}}},
|
||||
{"event": {"textOutput": {"content": "Hi"}}},
|
||||
{"event": {"contentEnd": {"stopReason": "PARTIAL_TURN"}}},
|
||||
{"event": {"contentStart": {"role": "ASSISTANT", "type": "AUDIO"}}},
|
||||
],
|
||||
)
|
||||
assert sum(1 for e in events if e["type"] == "response.created") == 1
|
||||
|
||||
def test_usage_event_propagates_to_response_done(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[
|
||||
{
|
||||
"event": {
|
||||
"usageEvent": {
|
||||
"totalInputTokens": 25,
|
||||
"totalOutputTokens": 40,
|
||||
"totalTokens": 65,
|
||||
"details": {
|
||||
"total": {
|
||||
"input": {"speechTokens": 20, "textTokens": 5},
|
||||
"output": {"speechTokens": 30, "textTokens": 10},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
{"event": {"contentStart": {"role": "ASSISTANT", "type": "TEXT"}}},
|
||||
{"event": {"textOutput": {"content": "Hi"}}},
|
||||
{"event": {"contentEnd": {"stopReason": "END_TURN"}}},
|
||||
],
|
||||
)
|
||||
done_events = [e for e in events if e["type"] == "response.done"]
|
||||
assert len(done_events) == 1
|
||||
usage = done_events[0]["response"]["usage"]
|
||||
assert usage["input_tokens"] == 25
|
||||
assert usage["output_tokens"] == 40
|
||||
assert usage["total_tokens"] == 65
|
||||
assert usage["input_token_details"]["audio_tokens"] == 20
|
||||
assert usage["input_token_details"]["text_tokens"] == 5
|
||||
assert usage["output_token_details"]["audio_tokens"] == 30
|
||||
assert usage["output_token_details"]["text_tokens"] == 10
|
||||
|
||||
def test_response_done_without_usage_event_reports_zero_usage(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[
|
||||
{"event": {"contentStart": {"role": "ASSISTANT", "type": "TEXT"}}},
|
||||
{"event": {"textOutput": {"content": "Hi"}}},
|
||||
{"event": {"contentEnd": {"stopReason": "END_TURN"}}},
|
||||
],
|
||||
)
|
||||
done_events = [e for e in events if e["type"] == "response.done"]
|
||||
assert len(done_events) == 1
|
||||
usage = done_events[0]["response"]["usage"]
|
||||
assert usage["input_tokens"] == 0
|
||||
assert usage["output_tokens"] == 0
|
||||
assert usage["total_tokens"] == 0
|
||||
|
||||
@staticmethod
|
||||
def _usage_event(total_input, total_output, in_speech, in_text, out_speech, out_text):
|
||||
return {
|
||||
"event": {
|
||||
"usageEvent": {
|
||||
"totalInputTokens": total_input,
|
||||
"totalOutputTokens": total_output,
|
||||
"totalTokens": total_input + total_output,
|
||||
"details": {
|
||||
"total": {
|
||||
"input": {"speechTokens": in_speech, "textTokens": in_text},
|
||||
"output": {"speechTokens": out_speech, "textTokens": out_text},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ASSISTANT_TURN = (
|
||||
{"event": {"contentStart": {"role": "ASSISTANT", "type": "TEXT"}}},
|
||||
{"event": {"textOutput": {"content": "Hi"}}},
|
||||
{"event": {"contentEnd": {"stopReason": "END_TURN"}}},
|
||||
)
|
||||
|
||||
def test_multi_turn_usage_reports_per_response_deltas_not_cumulative_totals(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[
|
||||
self._usage_event(25, 40, in_speech=20, in_text=5, out_speech=30, out_text=10),
|
||||
*self._ASSISTANT_TURN,
|
||||
self._usage_event(40, 100, in_speech=30, in_text=10, out_speech=75, out_text=25),
|
||||
*self._ASSISTANT_TURN,
|
||||
],
|
||||
)
|
||||
usages = [e["response"]["usage"] for e in events if e["type"] == "response.done"]
|
||||
assert len(usages) == 2
|
||||
assert (usages[0]["input_tokens"], usages[0]["output_tokens"], usages[0]["total_tokens"]) == (25, 40, 65)
|
||||
assert (usages[1]["input_tokens"], usages[1]["output_tokens"], usages[1]["total_tokens"]) == (15, 60, 75)
|
||||
assert usages[1]["input_token_details"] == {"audio_tokens": 10, "text_tokens": 5, "cached_tokens": 0}
|
||||
assert usages[1]["output_token_details"] == {"audio_tokens": 45, "text_tokens": 15}
|
||||
assert sum(u["total_tokens"] for u in usages) == 140
|
||||
|
||||
def test_usage_reported_after_last_response_done_flushes_as_logged_only_done(self):
|
||||
config = BedrockRealtimeConfig()
|
||||
self._run(
|
||||
config,
|
||||
[
|
||||
self._usage_event(25, 40, in_speech=20, in_text=5, out_speech=30, out_text=10),
|
||||
*self._ASSISTANT_TURN,
|
||||
],
|
||||
)
|
||||
assert config.leftover_usage_done_events() == ()
|
||||
|
||||
self._run(config, [self._usage_event(25, 46, in_speech=20, in_text=5, out_speech=30, out_text=16)])
|
||||
leftover = config.leftover_usage_done_events()
|
||||
assert len(leftover) == 1
|
||||
assert leftover[0]["type"] == "response.done"
|
||||
usage = leftover[0]["response"]["usage"]
|
||||
assert (usage["input_tokens"], usage["output_tokens"], usage["total_tokens"]) == (0, 6, 6)
|
||||
assert usage["output_token_details"] == {"audio_tokens": 0, "text_tokens": 6}
|
||||
assert config.leftover_usage_done_events() == ()
|
||||
|
||||
def test_final_transcript_fragments_emit_one_completed_with_full_transcript(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[
|
||||
{
|
||||
"event": {
|
||||
"contentStart": {
|
||||
"role": "USER",
|
||||
"type": "TEXT",
|
||||
"additionalModelFields": json.dumps({"generationStage": "FINAL"}),
|
||||
}
|
||||
}
|
||||
},
|
||||
{"event": {"textOutput": {"content": "What is the "}}},
|
||||
{"event": {"textOutput": {"content": "capital of France?"}}},
|
||||
{"event": {"contentEnd": {"stopReason": "PARTIAL_TURN"}}},
|
||||
],
|
||||
)
|
||||
deltas = [e for e in events if e["type"] == "conversation.item.input_audio_transcription.delta"]
|
||||
completed = [e for e in events if e["type"] == "conversation.item.input_audio_transcription.completed"]
|
||||
assert [d["delta"] for d in deltas] == ["What is the ", "capital of France?"]
|
||||
assert len(completed) == 1
|
||||
assert completed[0]["transcript"] == "What is the capital of France?"
|
||||
assert {e["item_id"] for e in deltas + completed} == {completed[0]["item_id"]}
|
||||
|
||||
def test_speculative_transcript_block_end_emits_no_completed(self):
|
||||
events = self._run(
|
||||
BedrockRealtimeConfig(),
|
||||
[
|
||||
{
|
||||
"event": {
|
||||
"contentStart": {
|
||||
"role": "USER",
|
||||
"type": "TEXT",
|
||||
"additionalModelFields": json.dumps({"generationStage": "SPECULATIVE"}),
|
||||
}
|
||||
}
|
||||
},
|
||||
{"event": {"textOutput": {"content": "rea"}}},
|
||||
{"event": {"contentEnd": {"stopReason": "PARTIAL_TURN"}}},
|
||||
],
|
||||
)
|
||||
assert [e["type"] for e in events] == ["conversation.item.input_audio_transcription.delta"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,22 @@ def mock_mcp_client_ip():
|
|||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_global_mcp_registry():
|
||||
"""Restore the module-global MCP server registry after each test.
|
||||
|
||||
Tests here register servers on ``global_mcp_server_manager`` directly; without a
|
||||
restore, entries leak into other test modules sharing the same worker and break
|
||||
assertions over the full registry contents.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
snapshot = dict(global_mcp_server_manager.registry)
|
||||
yield
|
||||
global_mcp_server_manager.registry.clear()
|
||||
global_mcp_server_manager.registry.update(snapshot)
|
||||
|
||||
|
||||
def _mock_callback_request(base_url: str = "http://localhost:3000/"):
|
||||
"""Return a MagicMock Request for callback/authorize same-origin tests.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
|
||||
def test_friendli_glm_5_3_flash_model_info():
|
||||
model = "friendliai/zai-org/GLM-5.3-Flash"
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
info = model_cost.get(model)
|
||||
assert (
|
||||
info is not None
|
||||
), f"{model} not found in model_prices_and_context_window.json"
|
||||
assert info["litellm_provider"] == "friendliai"
|
||||
assert info["mode"] == "chat"
|
||||
assert info["input_cost_per_token"] == 1.5e-07
|
||||
assert info["output_cost_per_token"] == 5e-07
|
||||
assert info["cache_read_input_token_cost"] == 3e-08
|
||||
assert info["max_input_tokens"] == 1048576
|
||||
assert info["max_output_tokens"] == 1048576
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_reasoning"] is True
|
||||
assert info["reasoning_effort_levels"] == ["low", "high", "max"]
|
||||
assert info["supports_tool_choice"] is True
|
||||
assert info["supports_prompt_caching"] is True
|
||||
assert info["supports_vision"] is True
|
||||
assert info["supports_image_input"] is True
|
||||
assert info["supports_video_input"] is True
|
||||
|
||||
routed_model, provider, _, _ = get_llm_provider(model=model)
|
||||
assert routed_model == "zai-org/GLM-5.3-Flash"
|
||||
assert provider == "friendliai"
|
||||
34
tests/test_litellm/test_friendli_glm_5_3_model_metadata.py
Normal file
34
tests/test_litellm/test_friendli_glm_5_3_model_metadata.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
|
||||
def test_friendli_glm_5_3_model_info():
|
||||
model = "friendliai/zai-org/GLM-5.3"
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
info = model_cost.get(model)
|
||||
assert (
|
||||
info is not None
|
||||
), f"{model} not found in model_prices_and_context_window.json"
|
||||
assert info["litellm_provider"] == "friendliai"
|
||||
assert info["mode"] == "chat"
|
||||
assert info["input_cost_per_token"] == 1.26e-06
|
||||
assert info["output_cost_per_token"] == 3.96e-06
|
||||
assert info["cache_read_input_token_cost"] == 2.34e-07
|
||||
assert info["max_input_tokens"] == 1048576
|
||||
assert info["max_output_tokens"] == 1048576
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_reasoning"] is True
|
||||
assert info["reasoning_effort_levels"] == ["low", "high", "max"]
|
||||
assert info["supports_tool_choice"] is True
|
||||
assert info["supports_prompt_caching"] is True
|
||||
assert info["supports_vision"] is False
|
||||
assert info["supports_image_input"] is False
|
||||
|
||||
routed_model, provider, _, _ = get_llm_provider(model=model)
|
||||
assert routed_model == "zai-org/GLM-5.3"
|
||||
assert provider == "friendliai"
|
||||
|
|
@ -8155,6 +8155,71 @@ class TestUpsertDeploymentRollback:
|
|||
assert len(router.model_list) == 1
|
||||
|
||||
|
||||
class TestUpsertDeploymentRename:
|
||||
"""
|
||||
Issue #38360: renaming a model wrote the new `model_name` to the db, but the reload's
|
||||
`upsert_deployment` compared only `litellm_params` and `model_info`. A rename with no
|
||||
other edit therefore compared equal and the router kept the old name until a restart,
|
||||
so `/model/info` and `/v1/models` served the stale name and the new one was unroutable.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _router() -> "litellm.Router":
|
||||
return litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "old-name",
|
||||
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-test"},
|
||||
"model_info": {"id": "rename-1", "db_model": True},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _deployment(model_name: str, tpm: int | None = None):
|
||||
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
|
||||
|
||||
return Deployment(
|
||||
model_name=model_name,
|
||||
litellm_params=LiteLLM_Params(model="openai/gpt-4o", api_key="sk-test", tpm=tpm),
|
||||
model_info=ModelInfo(id="rename-1", db_model=True),
|
||||
)
|
||||
|
||||
def test_rename_only_updates_the_router(self):
|
||||
router = self._router()
|
||||
|
||||
assert router.upsert_deployment(deployment=self._deployment("new-name")) is not None
|
||||
|
||||
assert [model["model_name"] for model in router.model_list] == ["new-name"]
|
||||
renamed = router.get_deployment(model_id="rename-1")
|
||||
assert renamed is not None
|
||||
assert renamed.model_name == "new-name"
|
||||
|
||||
def test_rename_only_makes_the_new_name_routable(self):
|
||||
router = self._router()
|
||||
|
||||
router.upsert_deployment(deployment=self._deployment("new-name"))
|
||||
|
||||
assert router.get_model_ids(model_name="new-name") == ["rename-1"]
|
||||
assert router.get_model_ids(model_name="old-name") == []
|
||||
|
||||
def test_rename_alongside_another_edit_still_updates(self):
|
||||
router = self._router()
|
||||
|
||||
router.upsert_deployment(deployment=self._deployment("new-name", tpm=1234))
|
||||
|
||||
assert router.get_model_ids(model_name="new-name") == ["rename-1"]
|
||||
renamed = router.get_deployment(model_id="rename-1")
|
||||
assert renamed is not None
|
||||
assert renamed.litellm_params.tpm == 1234
|
||||
|
||||
def test_unchanged_deployment_is_still_a_no_op(self):
|
||||
router = self._router()
|
||||
|
||||
assert router.upsert_deployment(deployment=self._deployment("old-name")) is None
|
||||
assert [model["model_name"] for model in router.model_list] == ["old-name"]
|
||||
|
||||
|
||||
class TestConsumedRequestTagsStamp:
|
||||
"""Issue #36621: when a request's tags select a tagged pre-routing strategy, those
|
||||
tags are consumed by the selection; the hook must stamp the rewritten model group so
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"limit": 269
|
||||
},
|
||||
"LIT004": {
|
||||
"limit": 43
|
||||
"limit": 40
|
||||
},
|
||||
"LIT005": {
|
||||
"limit": 0
|
||||
|
|
|
|||
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