mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_fix_realtime_cached_audio_cost
Some checks failed
ai-gateway image / ai-gateway release image (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
Some checks failed
ai-gateway image / ai-gateway release image (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> # Conflicts: # tests/test_litellm/test_cost_calculator.py
This commit is contained in:
commit
3aeae3c7fe
112 changed files with 5513 additions and 834 deletions
|
|
@ -780,7 +780,10 @@ async def update_project(
|
|||
|
||||
# Handle budget updates
|
||||
budget_fields = LiteLLM_BudgetTable.model_fields.keys()
|
||||
budget_updates = {k: v for k, v in update_data.items() if k in budget_fields}
|
||||
budget_updates = {
|
||||
**{k: v for k, v in update_data.items() if k in budget_fields},
|
||||
**({"max_budget": None} if "max_budget" in data.model_fields_set and data.max_budget is None else {}),
|
||||
}
|
||||
|
||||
if budget_updates and existing_project.budget_id:
|
||||
# Update existing budget
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.66"
|
||||
version = "0.1.67"
|
||||
description = "Package for LiteLLM Enterprise features"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.1.66"
|
||||
version = "0.1.67"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
|
|
@ -37,11 +37,13 @@ raised it above the deploy default keeps that larger budget for deploy unless
|
|||
the deploy override says otherwise.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -64,6 +66,7 @@ DEFAULT_PRISMA_BOOTSTRAP_TIMEOUT = 600.0
|
|||
DEFAULT_PRISMA_MIGRATE_DEPLOY_TIMEOUT = 600.0
|
||||
|
||||
BOOTSTRAP_ARG = "--version"
|
||||
PRISMA_CONSOLE_SCRIPT = "prisma"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -184,6 +187,28 @@ def _kill_process_group(process: "subprocess.Popen[str]") -> None:
|
|||
return
|
||||
|
||||
|
||||
def prisma_cli_available() -> bool:
|
||||
"""Whether some way of running the Prisma CLI exists: the console script on PATH or the importable package."""
|
||||
if shutil.which(PRISMA_CONSOLE_SCRIPT) is not None:
|
||||
return True
|
||||
return importlib.util.find_spec(PRISMA_CONSOLE_SCRIPT) is not None
|
||||
|
||||
|
||||
def resolve_prisma_argv(argv: Sequence[str]) -> tuple[str, ...]:
|
||||
"""Route a bare ``prisma`` command through ``python -m prisma`` when the console script is not on PATH.
|
||||
|
||||
The console script and ``python -m prisma`` are the same entry point, but
|
||||
only the module form survives an interpreter whose ``bin`` directory is
|
||||
missing from PATH, which is how the proxy gets started under launchers and
|
||||
init systems. Any other executable name is left untouched.
|
||||
"""
|
||||
if not argv or argv[0] != PRISMA_CONSOLE_SCRIPT:
|
||||
return tuple(argv)
|
||||
if shutil.which(PRISMA_CONSOLE_SCRIPT) is not None:
|
||||
return tuple(argv)
|
||||
return (sys.executable, "-m", PRISMA_CONSOLE_SCRIPT, *argv[1:])
|
||||
|
||||
|
||||
def run_prisma(
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
|
|
@ -200,7 +225,7 @@ def run_prisma(
|
|||
text unless ``stdout``/``stderr`` say otherwise.
|
||||
"""
|
||||
with subprocess.Popen(
|
||||
argv,
|
||||
resolve_prisma_argv(argv),
|
||||
env=env,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.96"
|
||||
version = "0.4.97"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.4.96"
|
||||
version = "0.4.97"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@ impl PythonLogger {
|
|||
params.set_item(name, value)?;
|
||||
}
|
||||
}
|
||||
for name in custom_pricing_fields(py)? {
|
||||
if let Some(value) = kwargs.bind(py).get_item(&name)?
|
||||
&& !value.is_none()
|
||||
{
|
||||
params.set_item(name, value)?;
|
||||
}
|
||||
}
|
||||
update.set_item("litellm_params", params)?;
|
||||
update.set_item("custom_llm_provider", &pre_call.custom_llm_provider)?;
|
||||
self.object(py)
|
||||
|
|
@ -120,6 +127,17 @@ impl PythonLogger {
|
|||
}
|
||||
}
|
||||
|
||||
fn custom_pricing_fields(py: Python<'_>) -> PyResult<Vec<String>> {
|
||||
py.import("litellm.types.utils")?
|
||||
.getattr("CustomPricingLiteLLMParams")?
|
||||
.getattr("model_fields")?
|
||||
.cast_into::<PyDict>()?
|
||||
.keys()
|
||||
.iter()
|
||||
.map(|name| name.extract::<String>())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn redact(
|
||||
py: Python<'_>,
|
||||
params: &Bound<'_, PyDict>,
|
||||
|
|
|
|||
|
|
@ -501,6 +501,7 @@ disable_copilot_system_to_assistant: bool = False # If false (default), convert
|
|||
public_mcp_servers: Optional[List[str]] = None
|
||||
public_mcp_hub_strict_whitelist: bool = True
|
||||
public_model_groups: Optional[List[str]] = None
|
||||
public_skills_index: bool = False
|
||||
public_agent_groups: Optional[List[str]] = None
|
||||
agent_search_embedding_model: Optional[str] = None
|
||||
mcp_tool_search: Optional[Mapping[str, object]] = None
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import inspect
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from collections.abc import Awaitable, Callable, Iterator, Sequence
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
|
@ -317,19 +317,37 @@ def _is_redis_health_failure(exc: BaseException) -> bool:
|
|||
def _redis_timeout_error_types() -> tuple[type, ...]:
|
||||
"""Health failures that are timeouts rather than unambiguous connectivity errors.
|
||||
|
||||
``builtins.TimeoutError`` covers ``asyncio.TimeoutError`` and ``socket.timeout``
|
||||
(aliases since py3.11 / py3.10). ``redis.exceptions.TimeoutError`` does not subclass
|
||||
either, so it is listed explicitly.
|
||||
``builtins.TimeoutError`` covers ``socket.timeout`` (an alias since py3.10) and, from
|
||||
py3.11, ``asyncio.TimeoutError``; on py3.10 ``asyncio.TimeoutError`` is still its own
|
||||
class, so it is listed explicitly. ``redis.exceptions.TimeoutError`` subclasses neither.
|
||||
"""
|
||||
try:
|
||||
from redis.exceptions import TimeoutError as RedisTimeoutError
|
||||
except ImportError:
|
||||
return (TimeoutError,)
|
||||
return (RedisTimeoutError, TimeoutError)
|
||||
return (TimeoutError, asyncio.TimeoutError)
|
||||
return (RedisTimeoutError, TimeoutError, asyncio.TimeoutError)
|
||||
|
||||
|
||||
_MAX_EXCEPTION_CAUSE_DEPTH: Final = 20
|
||||
|
||||
|
||||
def _explicit_causes(exc: BaseException) -> Iterator[BaseException]:
|
||||
current = exc # rebind-ok: advances one link per iteration of the bounded walk
|
||||
for _ in range(_MAX_EXCEPTION_CAUSE_DEPTH):
|
||||
yield current
|
||||
if current.__cause__ is None:
|
||||
return
|
||||
current = current.__cause__
|
||||
|
||||
|
||||
def _is_redis_timeout_failure(exc: BaseException) -> bool:
|
||||
return isinstance(exc, _redis_timeout_error_types())
|
||||
"""True when ``exc`` or any exception it was explicitly raised ``from`` is a timeout.
|
||||
|
||||
redis-py's blocking pool reports a pool wait timeout as ``ConnectionError`` chained from
|
||||
``asyncio.TimeoutError``, which is a busy pool rather than an unreachable Redis.
|
||||
"""
|
||||
timeout_types: Final = _redis_timeout_error_types()
|
||||
return any(isinstance(link, timeout_types) for link in _explicit_causes(exc))
|
||||
|
||||
|
||||
class _BreakerMetrics:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, cast
|
|||
|
||||
from httpx import Response
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
import litellm._logging
|
||||
|
|
@ -311,6 +312,15 @@ def _transcription_usage_has_token_details(
|
|||
return (prompt_tokens_val > 0) or (completion_tokens_val > 0)
|
||||
|
||||
|
||||
OCRPricingField = Literal["ocr_cost_per_page", "ocr_cost_per_credit", "annotation_cost_per_page"]
|
||||
|
||||
|
||||
class OCRPricing(TypedDict, total=False):
|
||||
ocr_cost_per_page: ReadOnly[float | None]
|
||||
ocr_cost_per_credit: ReadOnly[float | None]
|
||||
annotation_cost_per_page: ReadOnly[float | None]
|
||||
|
||||
|
||||
def cost_per_token(
|
||||
model: str = "",
|
||||
prompt_tokens: int = 0,
|
||||
|
|
@ -345,6 +355,7 @@ def cost_per_token(
|
|||
response: Any | None = None,
|
||||
### REQUEST MODEL ###
|
||||
request_model: str | None = None, # original request model for router detection
|
||||
custom_model_info: OCRPricing | None = None,
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Calculates the cost per token for a given model, prompt tokens, and completion tokens.
|
||||
|
|
@ -559,6 +570,7 @@ def cost_per_token(
|
|||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
response=response,
|
||||
model_info=custom_model_info,
|
||||
)
|
||||
elif (
|
||||
call_type == "aretrieve_batch"
|
||||
|
|
@ -1433,20 +1445,9 @@ def completion_cost(
|
|||
)
|
||||
elif call_type in _VIDEO_CALL_TYPES:
|
||||
### VIDEO GENERATION COST CALCULATION ###
|
||||
# Extract custom model_info for deployment-specific pricing
|
||||
_video_model_info: ModelInfo | None = None
|
||||
if custom_pricing and litellm_logging_obj is not None:
|
||||
_litellm_params = getattr(litellm_logging_obj, "litellm_params", None)
|
||||
if _litellm_params is not None:
|
||||
_video_model_info = next(
|
||||
(
|
||||
model_info
|
||||
for _metadata_key in ("metadata", "litellm_metadata")
|
||||
if (model_info := (_litellm_params.get(_metadata_key) or {}).get("model_info"))
|
||||
is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
_video_model_info: ModelInfo | None = _deployment_model_info(
|
||||
litellm_logging_obj, custom_pricing, router_model_id
|
||||
)
|
||||
|
||||
usage_obj = getattr(completion_response, "usage", None)
|
||||
duration_seconds: float | None = None
|
||||
|
|
@ -1666,6 +1667,7 @@ def completion_cost(
|
|||
data_residency=data_residency,
|
||||
vertex_location=vertex_location,
|
||||
response=completion_response,
|
||||
custom_model_info=_ocr_model_info(litellm_logging_obj, custom_pricing, router_model_id),
|
||||
)
|
||||
|
||||
# Get additional costs from provider (e.g., routing fees, infrastructure costs)
|
||||
|
|
@ -1899,16 +1901,82 @@ def response_cost_calculator(
|
|||
raise e
|
||||
|
||||
|
||||
def _deployment_model_info(
|
||||
litellm_logging_obj: LitellmLoggingObject | None,
|
||||
custom_pricing: bool | None,
|
||||
router_model_id: str | None,
|
||||
) -> ModelInfo | None:
|
||||
if not custom_pricing:
|
||||
return None
|
||||
registered_deployment_info: Final = (
|
||||
_cost_map_model_info(router_model_id, None)
|
||||
if router_model_id is not None and router_model_id in litellm.model_cost
|
||||
else None
|
||||
)
|
||||
if registered_deployment_info is not None:
|
||||
return registered_deployment_info
|
||||
if litellm_logging_obj is None:
|
||||
return None
|
||||
litellm_params: Final = getattr(litellm_logging_obj, "litellm_params", None)
|
||||
if litellm_params is None:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
model_info
|
||||
for metadata_key in ("metadata", "litellm_metadata")
|
||||
if (metadata := litellm_params.get(metadata_key)) and (model_info := metadata.get("model_info")) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _ocr_model_info(
|
||||
litellm_logging_obj: LitellmLoggingObject | None,
|
||||
custom_pricing: bool | None,
|
||||
router_model_id: str | None,
|
||||
) -> OCRPricing | None:
|
||||
deployment_info: Final = _deployment_model_info(litellm_logging_obj, custom_pricing, router_model_id)
|
||||
litellm_params: Final = getattr(litellm_logging_obj, "litellm_params", None) if custom_pricing else None
|
||||
if litellm_params is None:
|
||||
return deployment_info
|
||||
return _layered_ocr_pricing(litellm_params, deployment_info)
|
||||
|
||||
|
||||
def _first_ocr_price(field: OCRPricingField, *sources: Mapping[str, object] | None) -> float | None:
|
||||
return next(
|
||||
(price for source in sources if source is not None and isinstance(price := source.get(field), int | float)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _layered_ocr_pricing(*sources: Mapping[str, object] | None) -> OCRPricing:
|
||||
return OCRPricing(
|
||||
ocr_cost_per_page=_first_ocr_price("ocr_cost_per_page", *sources),
|
||||
ocr_cost_per_credit=_first_ocr_price("ocr_cost_per_credit", *sources),
|
||||
annotation_cost_per_page=_first_ocr_price("annotation_cost_per_page", *sources),
|
||||
)
|
||||
|
||||
|
||||
def _cost_map_model_info(model: str, custom_llm_provider: str | None) -> ModelInfo | None:
|
||||
try:
|
||||
return litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def ocr_cost(
|
||||
model: str,
|
||||
custom_llm_provider: str | None,
|
||||
response: object | None = None,
|
||||
model_info: OCRPricing | None = None,
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Args:
|
||||
model: str - model name
|
||||
custom_llm_provider: Optional[str] - custom LLM provider
|
||||
response: Optional[Any] - response object
|
||||
model_info: Optional[OCRPricing] - deployment-specific OCR pricing; each rate it sets
|
||||
overrides the model cost map's, the rest fall back to the map
|
||||
|
||||
Returns:
|
||||
Tuple[float, float]: cost of OCR processing
|
||||
|
|
@ -1926,20 +1994,15 @@ def ocr_cost(
|
|||
if response.usage_info is None:
|
||||
raise ValueError("OCR response usage_info is None")
|
||||
|
||||
try:
|
||||
model_info: ModelInfo | None = litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)
|
||||
except Exception:
|
||||
model_info = None
|
||||
|
||||
credits: Final = getattr(response.usage_info, "credits", None)
|
||||
cost_per_credit = None
|
||||
if model_info is not None:
|
||||
cost_per_credit = model_info.get("ocr_cost_per_credit")
|
||||
pricing: Final = _layered_ocr_pricing(model_info, _cost_map_model_info(model, custom_llm_provider))
|
||||
|
||||
cost_per_credit: Final = pricing.get("ocr_cost_per_credit")
|
||||
if credits is not None and cost_per_credit is not None:
|
||||
return cost_per_credit * credits, 0.0
|
||||
|
||||
ocr_cost_per_page: Final = model_info.get("ocr_cost_per_page") if model_info is not None else None
|
||||
annotation_cost_per_page: Final = model_info.get("annotation_cost_per_page") if model_info is not None else None
|
||||
ocr_cost_per_page: Final = pricing.get("ocr_cost_per_page")
|
||||
annotation_cost_per_page: Final = pricing.get("annotation_cost_per_page")
|
||||
annotation_rate: Final = annotation_cost_per_page if annotation_cost_per_page is not None else ocr_cost_per_page
|
||||
|
||||
pages_processed: Final = response.usage_info.pages_processed
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import os
|
|||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Optional, get_args
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -1279,6 +1280,7 @@ class CustomGuardrail(CustomLogger):
|
|||
guardrail_response: Final = self._summarize_guardrail_response(
|
||||
response=response,
|
||||
original_inputs=original_inputs,
|
||||
event_type=event_type,
|
||||
)
|
||||
|
||||
verbose_logger.debug("Guardrail response: %s", response)
|
||||
|
|
@ -1298,6 +1300,7 @@ class CustomGuardrail(CustomLogger):
|
|||
self,
|
||||
response: object,
|
||||
original_inputs: Mapping[str, object] | None,
|
||||
event_type: GuardrailEventHooks | None,
|
||||
) -> object:
|
||||
"""Reduce a hook's return value to what is safe to log as ``guardrail_response``.
|
||||
|
||||
|
|
@ -1305,15 +1308,21 @@ class CustomGuardrail(CustomLogger):
|
|||
returns the (possibly modified) request payload. Neither is a provider verdict, and
|
||||
logging them verbatim ships the user's prompt to every logging sink (OTEL spans,
|
||||
Datadog, spend logs), so both collapse to ``"allow"`` / ``"mask"`` by comparing
|
||||
against ``original_inputs``, a copy taken before the hook ran. A string result is the
|
||||
hook's own rejection message (the proxy turns it into a 400), not user input, so it is
|
||||
logged as is.
|
||||
against ``original_inputs``, a copy taken before the hook ran. A pre_call baseline only
|
||||
holds the prompt-bearing keys, so the returned request is narrowed to those same keys
|
||||
before the comparison. A string result is the hook's own rejection message (the proxy
|
||||
turns it into a 400), not user input, so it is logged as is.
|
||||
"""
|
||||
if response is None:
|
||||
return {}
|
||||
if original_inputs is None or not isinstance(response, Mapping):
|
||||
return response
|
||||
return "mask" if self._inputs_were_modified(original_inputs, response) else "allow"
|
||||
compared_response: Final[Mapping[str, object]] = (
|
||||
MappingProxyType({key: value for key, value in response.items() if key in _PRE_CALL_CONTENT_KEYS})
|
||||
if event_type == GuardrailEventHooks.pre_call
|
||||
else response
|
||||
)
|
||||
return "mask" if self._inputs_were_modified(original_inputs, compared_response) else "allow"
|
||||
|
||||
@staticmethod
|
||||
def _is_guardrail_intervention(e: Exception) -> bool:
|
||||
|
|
@ -1355,8 +1364,8 @@ class CustomGuardrail(CustomLogger):
|
|||
raise e
|
||||
|
||||
def _inputs_were_modified(self, original_inputs: Mapping[str, object], response: Mapping[str, object]) -> bool:
|
||||
"""True when any baseline key's value differs in ``response`` (mask), False otherwise (allow)."""
|
||||
return any(response.get(key) != value for key, value in original_inputs.items())
|
||||
"""True when any key of either mapping differs between them (mask), False otherwise (allow)."""
|
||||
return any(original_inputs.get(key) != response.get(key) for key in original_inputs.keys() | response.keys())
|
||||
|
||||
def mask_content_in_string(
|
||||
self,
|
||||
|
|
@ -1476,13 +1485,13 @@ def _original_inputs_for(
|
|||
) -> dict | None: # mutable-ok: matches _process_response(original_inputs=) signature
|
||||
"""Baseline the hook's return value is compared against to decide "allow" vs "mask".
|
||||
|
||||
``apply_guardrail`` masks a fresh ``inputs`` dict, so that dict is the baseline. Pre-call
|
||||
hooks edit the request in place and return it, so the baseline is a deep copy of the
|
||||
prompt-bearing keys taken before the hook runs.
|
||||
Hooks may edit their argument in place and return it, so the baseline is always a deep
|
||||
copy taken before the hook runs: the whole ``inputs`` dict for ``apply_guardrail``, the
|
||||
prompt-bearing request keys for pre-call hooks.
|
||||
"""
|
||||
if func_name == "apply_guardrail":
|
||||
inputs: Final = kwargs.get("inputs")
|
||||
return inputs if isinstance(inputs, dict) else None
|
||||
return copy.deepcopy(inputs) if isinstance(inputs, dict) else None
|
||||
if event_type != GuardrailEventHooks.pre_call:
|
||||
return None
|
||||
return {key: copy.deepcopy(value) for key, value in request_data.items() if key in _PRE_CALL_CONTENT_KEYS}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils):
|
|||
def __init__(self, bucket_name: str | None = None) -> None:
|
||||
from litellm.proxy.proxy_server import premium_user
|
||||
|
||||
super().__init__(bucket_name=bucket_name)
|
||||
|
||||
self.batch_size = int(os.getenv("GCS_BATCH_SIZE", GCS_DEFAULT_BATCH_SIZE))
|
||||
self.flush_interval = int(os.getenv("GCS_FLUSH_INTERVAL", GCS_DEFAULT_FLUSH_INTERVAL_SECONDS))
|
||||
self.use_batched_logging = (
|
||||
|
|
@ -38,6 +36,7 @@ class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils):
|
|||
)
|
||||
self.flush_lock = asyncio.Lock()
|
||||
super().__init__(
|
||||
bucket_name=bucket_name,
|
||||
flush_lock=self.flush_lock,
|
||||
batch_size=self.batch_size,
|
||||
flush_interval=self.flush_interval,
|
||||
|
|
|
|||
|
|
@ -445,6 +445,12 @@ class RealTimeStreaming:
|
|||
)
|
||||
sent = False
|
||||
for msg in transformed:
|
||||
if isinstance(msg, bytes):
|
||||
await self.provider_config.pace_backend_send(msg)
|
||||
await self.backend_ws.send(msg)
|
||||
self._content_sent_after_setup = True
|
||||
sent = True
|
||||
continue
|
||||
try:
|
||||
msg_obj = _decode_json_object(msg)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
|
|
@ -1013,7 +1019,7 @@ class RealTimeStreaming:
|
|||
cast(str, transcript),
|
||||
item_id=cast(str | None, event.get("item_id")),
|
||||
)
|
||||
if not blocked:
|
||||
if not blocked and not self._is_transcription_session:
|
||||
await self._send_to_backend(json.dumps({"type": "response.create"}))
|
||||
continue
|
||||
## LOGGING
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
|
@ -54,9 +55,12 @@ class BaseRealtimeConfig(ABC):
|
|||
message: str,
|
||||
model: str,
|
||||
session_configuration_request: str | None = None,
|
||||
) -> list[str]:
|
||||
) -> Sequence[str | bytes]:
|
||||
pass
|
||||
|
||||
async def pace_backend_send(self, message: bytes) -> None:
|
||||
return None
|
||||
|
||||
def is_setup_message(self, msg_obj: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
|
@ -79,7 +83,7 @@ class BaseRealtimeConfig(ABC):
|
|||
model: str,
|
||||
logging_session_id: str,
|
||||
session_configuration_request: str | None = None,
|
||||
) -> dict | OpenAIRealtimeStreamSessionEvents | None:
|
||||
) -> Mapping[str, object] | OpenAIRealtimeStreamSessionEvents | None:
|
||||
"""
|
||||
Optional hook for providers that defer session setup until client `session.update`.
|
||||
|
||||
|
|
|
|||
719
litellm/llms/meta/realtime/transformation.py
Normal file
719
litellm/llms/meta/realtime/transformation.py
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from pydantic import JsonValue, TypeAdapter, ValidationError
|
||||
|
||||
from litellm import verbose_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.meta import MuseAudioEncoding, MuseHandshake, MuseMode, MuseSampleRate
|
||||
from litellm.types.llms.openai import (
|
||||
OpenAIRealtimeErrorEvent,
|
||||
OpenAIRealtimeEvents,
|
||||
OpenAIRealtimeInputAudioBufferSpeechEvent,
|
||||
OpenAIRealtimeInputAudioTranscriptionCompleted,
|
||||
OpenAIRealtimeInputAudioTranscriptionDelta,
|
||||
OpenAIRealtimeServerVadTurnDetection,
|
||||
OpenAIRealtimeTranscriptionSession,
|
||||
OpenAIRealtimeTranscriptionSessionCreated,
|
||||
OpenAIRealtimeTranscriptionSettings,
|
||||
)
|
||||
from litellm.types.realtime import (
|
||||
RealtimeInputAudioTranscriptionDurationUsage,
|
||||
RealtimeInputAudioTranscriptionUsage,
|
||||
RealtimeResponseTransformInput,
|
||||
RealtimeResponseTypedDict,
|
||||
)
|
||||
|
||||
MUSE_MODEL: Final = "muse-voice-transcribe-1.0"
|
||||
DEFAULT_MUSE_REALTIME_URL: Final = "wss://api.meta.ai/v1/asr/realtime"
|
||||
SUPPORTED_SAMPLE_RATES: Final = frozenset((16_000, 24_000))
|
||||
SUPPORTED_LANGUAGES: Final = (
|
||||
"Arabic",
|
||||
"Bengali",
|
||||
"Dutch",
|
||||
"English",
|
||||
"French",
|
||||
"German",
|
||||
"Hebrew",
|
||||
"Hindi",
|
||||
"Indonesian",
|
||||
"Italian",
|
||||
"Japanese",
|
||||
"Kannada",
|
||||
"Korean",
|
||||
"Malay",
|
||||
"Mandarin Chinese",
|
||||
"Marathi",
|
||||
"Polish",
|
||||
"Portuguese",
|
||||
"Spanish",
|
||||
"Tagalog",
|
||||
"Tamil",
|
||||
"Telugu",
|
||||
"Thai",
|
||||
"Turkish",
|
||||
"Vietnamese",
|
||||
)
|
||||
_LANGUAGE_NAMES: Final = MappingProxyType({language.casefold(): language for language in SUPPORTED_LANGUAGES})
|
||||
_LANGUAGE_CODES: Final = MappingProxyType(
|
||||
{
|
||||
"ar": "Arabic",
|
||||
"bn": "Bengali",
|
||||
"de": "German",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"fil": "Tagalog",
|
||||
"fr": "French",
|
||||
"he": "Hebrew",
|
||||
"hi": "Hindi",
|
||||
"id": "Indonesian",
|
||||
"it": "Italian",
|
||||
"iw": "Hebrew",
|
||||
"ja": "Japanese",
|
||||
"kn": "Kannada",
|
||||
"ko": "Korean",
|
||||
"ms": "Malay",
|
||||
"mr": "Marathi",
|
||||
"nl": "Dutch",
|
||||
"pl": "Polish",
|
||||
"pt": "Portuguese",
|
||||
"ta": "Tamil",
|
||||
"te": "Telugu",
|
||||
"th": "Thai",
|
||||
"tl": "Tagalog",
|
||||
"tr": "Turkish",
|
||||
"vi": "Vietnamese",
|
||||
"zh": "Mandarin Chinese",
|
||||
}
|
||||
)
|
||||
_SUPPORTED_TRANSCRIPTION_KEYS: Final = frozenset(("model", "language"))
|
||||
_MAX_AUDIO_BACKLOG_SECONDS: Final = 4
|
||||
_PACKET_MS: Final = 80
|
||||
_END_STREAM: Final = '{"type":"endStream"}'
|
||||
_PROVIDER_ERROR_MESSAGE: Final = "Meta Muse realtime transcription failed"
|
||||
_JSON_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
||||
_EMPTY_OBJECT: Final[Mapping[str, JsonValue]] = MappingProxyType({})
|
||||
_SERVER_VAD: Final[OpenAIRealtimeServerVadTurnDetection] = {"type": "server_vad"}
|
||||
|
||||
|
||||
class MuseProtocolError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MuseSessionConfig:
|
||||
model: str
|
||||
mode: MuseMode
|
||||
sample_rate: MuseSampleRate
|
||||
language_bias: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def audio_encoding(self) -> MuseAudioEncoding:
|
||||
return "PCM_16KHZ" if self.sample_rate == 16_000 else "PCM_24KHZ"
|
||||
|
||||
@property
|
||||
def bytes_per_second(self) -> int:
|
||||
return self.sample_rate * 2
|
||||
|
||||
@property
|
||||
def packet_bytes(self) -> int:
|
||||
return self.bytes_per_second * _PACKET_MS // 1000
|
||||
|
||||
@property
|
||||
def max_encoded_append_bytes(self) -> int:
|
||||
return 4 * ((self.bytes_per_second * _MAX_AUDIO_BACKLOG_SECONDS + 2) // 3)
|
||||
|
||||
def handshake(self, access_token: str) -> MuseHandshake:
|
||||
base: Final[MuseHandshake] = {
|
||||
"authorization": {"accessToken": access_token},
|
||||
"audioEncoding": self.audio_encoding,
|
||||
"model": self.model,
|
||||
"mode": self.mode,
|
||||
"partialMode": "CUMULATIVE",
|
||||
"emitAudioProgress": True,
|
||||
}
|
||||
if not self.language_bias:
|
||||
return base
|
||||
biased: Final[MuseHandshake] = {**base, "languageBias": self.language_bias}
|
||||
return biased
|
||||
|
||||
def openai_session(self, session_id: str) -> OpenAIRealtimeTranscriptionSession:
|
||||
session: Final[OpenAIRealtimeTranscriptionSession] = {
|
||||
"id": session_id,
|
||||
"object": "realtime.transcription_session",
|
||||
"type": "transcription",
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": {"type": "audio/pcm", "rate": self.sample_rate},
|
||||
"transcription": self._transcription_settings(),
|
||||
"turn_detection": None if self.mode == "PUSH_TO_TALK" else _SERVER_VAD,
|
||||
}
|
||||
},
|
||||
}
|
||||
return session
|
||||
|
||||
def _transcription_settings(self) -> OpenAIRealtimeTranscriptionSettings:
|
||||
base: Final[OpenAIRealtimeTranscriptionSettings] = {"model": self.model}
|
||||
if not self.language_bias:
|
||||
return base
|
||||
localized: Final[OpenAIRealtimeTranscriptionSettings] = {**base, "language": self.language_bias[0]}
|
||||
return localized
|
||||
|
||||
|
||||
_DEFAULT_SESSION_CONFIG: Final = MuseSessionConfig(
|
||||
model=MUSE_MODEL, mode="ENDPOINTING", sample_rate=24_000, language_bias=()
|
||||
)
|
||||
|
||||
|
||||
def _json_object(payload: str) -> Mapping[str, JsonValue]:
|
||||
try:
|
||||
value: Final = _JSON_ADAPTER.validate_json(payload)
|
||||
except ValidationError:
|
||||
raise MuseProtocolError("invalid JSON object") from None
|
||||
if not isinstance(value, dict):
|
||||
raise MuseProtocolError("message must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _mapping(value: JsonValue | None, name: str) -> Mapping[str, JsonValue]:
|
||||
if value is None:
|
||||
return _EMPTY_OBJECT
|
||||
if not isinstance(value, dict):
|
||||
raise MuseProtocolError(f"{name} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _string(value: JsonValue | None, name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise MuseProtocolError(f"{name} must be a string")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_model(model: str) -> str:
|
||||
return model.removeprefix("meta/").strip()
|
||||
|
||||
|
||||
def _event_id() -> str:
|
||||
return f"event_{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def normalize_language(language: str) -> str:
|
||||
value: Final = language.strip()
|
||||
if not value:
|
||||
raise MuseProtocolError("language must be non-empty")
|
||||
documented_name: Final = _LANGUAGE_NAMES.get(value.casefold())
|
||||
if documented_name is not None:
|
||||
return documented_name
|
||||
primary: Final = value.replace("_", "-").split("-", 1)[0].casefold()
|
||||
mapped_name: Final = _LANGUAGE_CODES.get(primary)
|
||||
if mapped_name is None:
|
||||
raise MuseProtocolError("unsupported Muse Voice language")
|
||||
return mapped_name
|
||||
|
||||
|
||||
def normalize_access_token(api_key: str) -> str:
|
||||
stripped: Final = api_key.strip()
|
||||
if not stripped:
|
||||
raise ValueError("Meta API key is required")
|
||||
parts: Final = stripped.split(None, 1)
|
||||
if parts[0].casefold() != "bearer":
|
||||
return f"Bearer {stripped}"
|
||||
if len(parts) != 2 or not parts[1].strip():
|
||||
raise ValueError("Meta API key must include a token after Bearer")
|
||||
return f"Bearer {parts[1].strip()}"
|
||||
|
||||
|
||||
def build_muse_realtime_url(api_base: str | None) -> str:
|
||||
if api_base is None:
|
||||
return DEFAULT_MUSE_REALTIME_URL
|
||||
parsed: Final = urlparse(api_base.strip())
|
||||
scheme: Final = "wss" if parsed.scheme == "https" else parsed.scheme
|
||||
if (
|
||||
scheme != "wss"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError("Meta api_base must be an absolute wss:// or https:// URL without credentials or a fragment")
|
||||
netloc: Final = f"{parsed.hostname}:{parsed.port}" if parsed.port is not None else parsed.hostname
|
||||
return urlunparse((scheme, netloc, "/v1/asr/realtime", "", "", ""))
|
||||
|
||||
|
||||
def _parse_sample_rate(session: Mapping[str, JsonValue]) -> MuseSampleRate:
|
||||
beta_format: Final = session.get("input_audio_format")
|
||||
audio: Final = _mapping(session.get("audio"), "session.audio")
|
||||
audio_input: Final = _mapping(audio.get("input"), "session.audio.input")
|
||||
ga_format: Final = audio_input.get("format")
|
||||
if beta_format is not None and ga_format is not None:
|
||||
raise MuseProtocolError("input audio format must use either beta or GA layout")
|
||||
if beta_format is not None:
|
||||
if beta_format != "pcm16":
|
||||
raise MuseProtocolError("Muse Voice requires pcm16 input audio")
|
||||
return 24_000
|
||||
if ga_format is None:
|
||||
return 24_000
|
||||
if isinstance(ga_format, str):
|
||||
if ga_format != "pcm16":
|
||||
raise MuseProtocolError("Muse Voice requires audio/pcm input audio")
|
||||
return 24_000
|
||||
format_mapping: Final = _mapping(ga_format, "session.audio.input.format")
|
||||
if format_mapping.get("type") != "audio/pcm":
|
||||
raise MuseProtocolError("Muse Voice requires audio/pcm input audio")
|
||||
channels: Final = format_mapping.get("channels", 1)
|
||||
if isinstance(channels, bool) or channels != 1:
|
||||
raise MuseProtocolError("Muse Voice requires mono input audio")
|
||||
rate: Final = format_mapping.get("rate", 24_000)
|
||||
if isinstance(rate, bool) or not isinstance(rate, int) or rate not in SUPPORTED_SAMPLE_RATES:
|
||||
raise MuseProtocolError("Muse Voice supports PCM16 at 16000 Hz or 24000 Hz")
|
||||
return 16_000 if rate == 16_000 else 24_000
|
||||
|
||||
|
||||
def _parse_mode(session: Mapping[str, JsonValue], audio_input: Mapping[str, JsonValue]) -> MuseMode:
|
||||
turn_detection_present: Final = "turn_detection" in session or "turn_detection" in audio_input
|
||||
turn_detection: Final = session.get("turn_detection", audio_input.get("turn_detection"))
|
||||
if turn_detection_present and turn_detection is None:
|
||||
return "PUSH_TO_TALK"
|
||||
if turn_detection is None:
|
||||
return "ENDPOINTING"
|
||||
turn_detection_mapping: Final = _mapping(turn_detection, "turn_detection")
|
||||
if turn_detection_mapping.get("type") not in (None, "server_vad"):
|
||||
raise MuseProtocolError("Muse Voice supports server_vad turn detection or null")
|
||||
return "ENDPOINTING"
|
||||
|
||||
|
||||
def parse_session_update(payload: str, expected_model: str) -> MuseSessionConfig:
|
||||
message: Final = _json_object(payload)
|
||||
if message.get("type") not in ("session.update", "transcription_session.update"):
|
||||
raise MuseProtocolError("expected session.update")
|
||||
session: Final = _mapping(message.get("session"), "session")
|
||||
if not session:
|
||||
raise MuseProtocolError("session.update requires a session object")
|
||||
if session.get("type") not in (None, "transcription", "realtime"):
|
||||
raise MuseProtocolError("Muse Voice supports transcription sessions only")
|
||||
audio: Final = _mapping(session.get("audio"), "session.audio")
|
||||
audio_input: Final = _mapping(audio.get("input"), "session.audio.input")
|
||||
beta_transcription: Final = session.get("input_audio_transcription")
|
||||
ga_transcription: Final = audio_input.get("transcription")
|
||||
if beta_transcription is not None and ga_transcription is not None:
|
||||
raise MuseProtocolError("input transcription must use either beta or GA layout")
|
||||
transcription: Final = _mapping(
|
||||
beta_transcription if beta_transcription is not None else ga_transcription,
|
||||
"input audio transcription",
|
||||
)
|
||||
unsupported: Final = tuple(sorted(key for key in transcription if key not in _SUPPORTED_TRANSCRIPTION_KEYS))
|
||||
if unsupported:
|
||||
verbose_logger.warning("Meta realtime: dropping unsupported transcription settings %s", unsupported)
|
||||
requested_model: Final = _string(transcription.get("model"), "transcription model")
|
||||
normalized_model: Final = _normalize_model(expected_model)
|
||||
if normalized_model != MUSE_MODEL:
|
||||
raise MuseProtocolError("unsupported Meta realtime model")
|
||||
if requested_model is not None and _normalize_model(requested_model) != normalized_model:
|
||||
raise MuseProtocolError("realtime session model cannot be changed")
|
||||
language: Final = _string(transcription.get("language"), "language")
|
||||
return MuseSessionConfig(
|
||||
model=normalized_model,
|
||||
mode=_parse_mode(session, audio_input),
|
||||
sample_rate=_parse_sample_rate(session),
|
||||
language_bias=() if language is None else (normalize_language(language),),
|
||||
)
|
||||
|
||||
|
||||
def session_created_event(config: MuseSessionConfig, session_id: str) -> OpenAIRealtimeTranscriptionSessionCreated:
|
||||
event: Final[OpenAIRealtimeTranscriptionSessionCreated] = {
|
||||
"type": "session.created",
|
||||
"event_id": _event_id(),
|
||||
"session": config.openai_session(session_id),
|
||||
}
|
||||
return event
|
||||
|
||||
|
||||
def error_event(message: str) -> OpenAIRealtimeErrorEvent:
|
||||
event: Final[OpenAIRealtimeErrorEvent] = {
|
||||
"type": "error",
|
||||
"error": {"type": "server_error", "message": message},
|
||||
}
|
||||
return event
|
||||
|
||||
|
||||
def _speech_event(
|
||||
event_type: Literal["input_audio_buffer.speech_started", "input_audio_buffer.speech_stopped"], item_id: str
|
||||
) -> OpenAIRealtimeInputAudioBufferSpeechEvent:
|
||||
event: Final[OpenAIRealtimeInputAudioBufferSpeechEvent] = {
|
||||
"type": event_type,
|
||||
"event_id": _event_id(),
|
||||
"item_id": item_id,
|
||||
}
|
||||
return event
|
||||
|
||||
|
||||
def _delta_event(item_id: str, delta: str) -> OpenAIRealtimeInputAudioTranscriptionDelta:
|
||||
event: Final[OpenAIRealtimeInputAudioTranscriptionDelta] = {
|
||||
"type": "conversation.item.input_audio_transcription.delta",
|
||||
"event_id": _event_id(),
|
||||
"item_id": item_id,
|
||||
"content_index": 0,
|
||||
"delta": delta,
|
||||
}
|
||||
return event
|
||||
|
||||
|
||||
def _completed_event(
|
||||
item_id: str, transcript: str, usage: RealtimeInputAudioTranscriptionUsage | None
|
||||
) -> OpenAIRealtimeInputAudioTranscriptionCompleted:
|
||||
event: Final[OpenAIRealtimeInputAudioTranscriptionCompleted] = {
|
||||
"type": "conversation.item.input_audio_transcription.completed",
|
||||
"event_id": _event_id(),
|
||||
"item_id": item_id,
|
||||
"content_index": 0,
|
||||
"transcript": transcript,
|
||||
}
|
||||
if usage is None:
|
||||
return event
|
||||
billed: Final[OpenAIRealtimeInputAudioTranscriptionCompleted] = {**event, "usage": usage}
|
||||
return billed
|
||||
|
||||
|
||||
def _required_turn_id(message: Mapping[str, JsonValue], event: str) -> str:
|
||||
value: Final = message.get("turnId")
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
||||
raise MuseProtocolError(f"{event} event has invalid turnId")
|
||||
turn_id: Final = str(value).strip()
|
||||
if not turn_id:
|
||||
raise MuseProtocolError(f"{event} event has invalid turnId")
|
||||
return turn_id
|
||||
|
||||
|
||||
def _new_suffix(previous: str, current: str) -> str:
|
||||
return current[len(previous) :] if current.startswith(previous) else ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _TurnState:
|
||||
item_id: str
|
||||
started: bool = False
|
||||
start_emitted: bool = False
|
||||
latest_partial: str | None = None
|
||||
emitted_partial: str = ""
|
||||
final_text: str | None = None
|
||||
completed_emitted: bool = False
|
||||
stopped: bool = False
|
||||
stopped_emitted: bool = False
|
||||
|
||||
def finish(self, transcript: str) -> None:
|
||||
self.final_text = transcript
|
||||
self.stopped = True
|
||||
|
||||
def drain(
|
||||
self, take_usage: Callable[[], RealtimeInputAudioTranscriptionUsage | None]
|
||||
) -> Iterator[OpenAIRealtimeEvents]:
|
||||
has_content: Final = self.latest_partial is not None or self.final_text is not None
|
||||
if (self.started or has_content) and not self.start_emitted:
|
||||
self.start_emitted = True
|
||||
yield _speech_event("input_audio_buffer.speech_started", self.item_id)
|
||||
if self.latest_partial is not None and self.final_text is None:
|
||||
delta: Final = _new_suffix(self.emitted_partial, self.latest_partial)
|
||||
if delta:
|
||||
self.emitted_partial = self.latest_partial
|
||||
yield _delta_event(self.item_id, delta)
|
||||
if self.stopped and not self.stopped_emitted:
|
||||
self.stopped_emitted = True
|
||||
yield _speech_event("input_audio_buffer.speech_stopped", self.item_id)
|
||||
if self.final_text is not None and self.stopped_emitted and not self.completed_emitted:
|
||||
self.completed_emitted = True
|
||||
yield _completed_event(self.item_id, self.final_text, take_usage())
|
||||
|
||||
|
||||
class MuseEventTransformer:
|
||||
def __init__(self, *, turn_limit: int = 128) -> None:
|
||||
self._turns: dict[str, _TurnState] = {} # mutable-ok: bounded, insertion-ordered per-turn emit state
|
||||
self._turn_limit: Final = turn_limit
|
||||
self._active_turn_id: str | None = None
|
||||
self._mode: MuseMode = "ENDPOINTING"
|
||||
self._last_audio_processed_ms: float = 0.0
|
||||
self._unbilled_seconds: float = 0.0
|
||||
|
||||
def configure(self, config: MuseSessionConfig) -> None:
|
||||
self._mode = config.mode
|
||||
|
||||
def transform(self, message: Mapping[str, JsonValue]) -> tuple[OpenAIRealtimeEvents, ...]:
|
||||
event_type: Final = message.get("type")
|
||||
if event_type == "error":
|
||||
return (error_event(_PROVIDER_ERROR_MESSAGE),)
|
||||
if event_type == "audioProgress":
|
||||
self._update_audio_progress(message)
|
||||
return ()
|
||||
turn: Final = self._apply_turn_event(event_type, message)
|
||||
if turn is None:
|
||||
return ()
|
||||
return tuple(turn.drain(self.take_unbilled_usage))
|
||||
|
||||
def take_unbilled_usage(self) -> RealtimeInputAudioTranscriptionUsage | None:
|
||||
seconds: Final = self._unbilled_seconds
|
||||
if seconds <= 0:
|
||||
return None
|
||||
self._unbilled_seconds = 0.0
|
||||
usage: Final[RealtimeInputAudioTranscriptionDurationUsage] = {"type": "duration", "seconds": seconds}
|
||||
return usage
|
||||
|
||||
def _apply_turn_event(self, event_type: JsonValue | None, message: Mapping[str, JsonValue]) -> _TurnState | None:
|
||||
match event_type:
|
||||
case "speechStart":
|
||||
return self._speech_start(message)
|
||||
case "transcript":
|
||||
return self._transcript(message)
|
||||
case "speechEnd":
|
||||
return self._speech_end(message)
|
||||
case "speechComplete":
|
||||
return self._speech_complete(message)
|
||||
case _:
|
||||
return None
|
||||
|
||||
def _turn(self, turn_id: str) -> _TurnState:
|
||||
existing: Final = self._turns.get(turn_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
created: Final = _TurnState(item_id=turn_id)
|
||||
self._turns[turn_id] = created
|
||||
if len(self._turns) > self._turn_limit:
|
||||
del self._turns[next(iter(self._turns))]
|
||||
return created
|
||||
|
||||
def _speech_start(self, message: Mapping[str, JsonValue]) -> _TurnState:
|
||||
turn: Final = self._turn(_required_turn_id(message, "speechStart"))
|
||||
if turn.stopped:
|
||||
return turn
|
||||
turn.started = True
|
||||
self._active_turn_id = turn.item_id
|
||||
return turn
|
||||
|
||||
def _transcript(self, message: Mapping[str, JsonValue]) -> _TurnState | None:
|
||||
transcript: Final = message.get("transcript")
|
||||
if not isinstance(transcript, str):
|
||||
raise MuseProtocolError("transcript event has invalid transcript")
|
||||
if not transcript and message.get("turnId") is None and self._active_turn_id is None:
|
||||
return None
|
||||
turn: Final = self._turn(self._transcript_turn_id(message))
|
||||
if message.get("final") is True:
|
||||
self._finish(turn, transcript)
|
||||
elif turn.final_text is None:
|
||||
turn.latest_partial = transcript
|
||||
return turn
|
||||
|
||||
def _speech_end(self, message: Mapping[str, JsonValue]) -> _TurnState:
|
||||
turn: Final = self._turn(_required_turn_id(message, "speechEnd"))
|
||||
turn.stopped = True
|
||||
return turn
|
||||
|
||||
def _speech_complete(self, message: Mapping[str, JsonValue]) -> _TurnState:
|
||||
transcript: Final = message.get("transcript")
|
||||
if not isinstance(transcript, str):
|
||||
raise MuseProtocolError("speechComplete event has invalid transcript")
|
||||
turn: Final = self._turn(_required_turn_id(message, "speechComplete"))
|
||||
self._finish(turn, transcript)
|
||||
return turn
|
||||
|
||||
def _finish(self, turn: _TurnState, transcript: str) -> None:
|
||||
turn.finish(transcript)
|
||||
self._release_active(turn)
|
||||
|
||||
def _release_active(self, turn: _TurnState) -> None:
|
||||
if self._active_turn_id == turn.item_id:
|
||||
self._active_turn_id = None
|
||||
|
||||
def _update_audio_progress(self, message: Mapping[str, JsonValue]) -> None:
|
||||
processed_ms: Final = message.get("audioProcessedMs")
|
||||
if (
|
||||
isinstance(processed_ms, bool)
|
||||
or not isinstance(processed_ms, (int, float))
|
||||
or not math.isfinite(processed_ms)
|
||||
or processed_ms < 0
|
||||
):
|
||||
raise MuseProtocolError("audioProgress event has invalid audioProcessedMs")
|
||||
if processed_ms <= self._last_audio_processed_ms:
|
||||
return
|
||||
self._unbilled_seconds += (float(processed_ms) - self._last_audio_processed_ms) / 1000
|
||||
self._last_audio_processed_ms = float(processed_ms)
|
||||
|
||||
def _transcript_turn_id(self, message: Mapping[str, JsonValue]) -> str:
|
||||
if message.get("turnId") is not None:
|
||||
return _required_turn_id(message, "transcript")
|
||||
if self._active_turn_id is not None:
|
||||
return self._active_turn_id
|
||||
if self._mode != "PUSH_TO_TALK":
|
||||
raise MuseProtocolError("transcript event is missing turnId outside an active turn")
|
||||
turn_id: Final = f"item_{uuid.uuid4().hex}"
|
||||
self._active_turn_id = turn_id
|
||||
return turn_id
|
||||
|
||||
|
||||
class MetaRealtimeConfig(BaseRealtimeConfig):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
) -> None:
|
||||
self._monotonic: Final = monotonic
|
||||
self._sleep: Final = sleep
|
||||
self._transformer: Final = MuseEventTransformer()
|
||||
self._access_token: str | None = None
|
||||
self._config: MuseSessionConfig | None = None
|
||||
self._pending_audio: bytes = b""
|
||||
self._end_stream_sent: bool = False
|
||||
self._pacing_origin: float | None = None
|
||||
self._sent_duration: float = 0.0
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict[str, str], # mutable-ok: BaseRealtimeConfig contract
|
||||
model: str,
|
||||
api_key: str | None = None,
|
||||
) -> dict[str, str]: # mutable-ok: BaseRealtimeConfig contract
|
||||
token: Final = api_key or get_secret_str("META_API_KEY")
|
||||
if token is None:
|
||||
raise ValueError("api_key is required for Meta API calls")
|
||||
self._access_token = normalize_access_token(token)
|
||||
return headers
|
||||
|
||||
def get_complete_url(self, api_base: str | None, model: str, api_key: str | None = None) -> str:
|
||||
if _normalize_model(model) != MUSE_MODEL:
|
||||
raise ValueError(f"Unsupported Meta realtime model: {model}")
|
||||
return build_muse_realtime_url(api_base)
|
||||
|
||||
def is_setup_message(self, msg_obj: Mapping[str, object]) -> bool:
|
||||
return "authorization" in msg_obj
|
||||
|
||||
def transform_session_created_event(
|
||||
self,
|
||||
model: str,
|
||||
logging_session_id: str,
|
||||
session_configuration_request: str | None = None,
|
||||
) -> OpenAIRealtimeTranscriptionSessionCreated:
|
||||
return session_created_event(_DEFAULT_SESSION_CONFIG, logging_session_id)
|
||||
|
||||
def transform_realtime_request(
|
||||
self,
|
||||
message: str,
|
||||
model: str,
|
||||
session_configuration_request: str | None = None,
|
||||
) -> tuple[str | bytes, ...]:
|
||||
request: Final = _json_object(message)
|
||||
event_type: Final = request.get("type")
|
||||
if event_type in ("session.update", "transcription_session.update"):
|
||||
return self._configure(message, model)
|
||||
if event_type == "input_audio_buffer.append":
|
||||
return self._append_audio(request)
|
||||
if event_type == "input_audio_buffer.commit":
|
||||
return self._flush_audio(end_stream=self._require_config().mode == "PUSH_TO_TALK")
|
||||
if event_type == "input_audio_buffer.end":
|
||||
return self._flush_audio(end_stream=True)
|
||||
if event_type == "input_audio_buffer.clear":
|
||||
self._pending_audio = b""
|
||||
return ()
|
||||
verbose_logger.debug("Meta realtime: dropping unsupported client event %s", event_type)
|
||||
return ()
|
||||
|
||||
async def pace_backend_send(self, message: bytes) -> None:
|
||||
now: Final = self._monotonic()
|
||||
origin: Final = self._pacing_origin
|
||||
effective_origin: Final = (
|
||||
now - self._sent_duration if origin is None or now > origin + self._sent_duration else origin
|
||||
)
|
||||
delay: Final = effective_origin + self._sent_duration - now
|
||||
if delay > 0:
|
||||
await self._sleep(delay)
|
||||
self._pacing_origin = effective_origin
|
||||
self._sent_duration += len(message) / self._require_config().bytes_per_second
|
||||
|
||||
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
|
||||
return self._transformer.take_unbilled_usage()
|
||||
|
||||
def transform_realtime_response(
|
||||
self,
|
||||
message: str | bytes,
|
||||
model: str,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
realtime_response_transform_input: RealtimeResponseTransformInput,
|
||||
) -> RealtimeResponseTypedDict:
|
||||
payload: Final = message.decode("utf-8") if isinstance(message, bytes) else message
|
||||
result: Final[RealtimeResponseTypedDict] = {
|
||||
"response": list(self._backend_events(payload)), # mutable-ok: RealtimeResponseTypedDict.response is a list
|
||||
"current_output_item_id": realtime_response_transform_input.get("current_output_item_id"),
|
||||
"current_response_id": realtime_response_transform_input.get("current_response_id"),
|
||||
"current_delta_chunks": realtime_response_transform_input.get("current_delta_chunks"),
|
||||
"current_conversation_id": realtime_response_transform_input.get("current_conversation_id"),
|
||||
"current_item_chunks": realtime_response_transform_input.get("current_item_chunks"),
|
||||
"current_delta_type": realtime_response_transform_input.get("current_delta_type"),
|
||||
"session_configuration_request": realtime_response_transform_input.get("session_configuration_request"),
|
||||
}
|
||||
return result
|
||||
|
||||
def _backend_events(self, payload: str) -> tuple[OpenAIRealtimeEvents, ...]:
|
||||
frame: Final = _json_object(payload)
|
||||
session_id: Final = frame.get("sessionId")
|
||||
if session_id is None:
|
||||
return self._transformer.transform(frame)
|
||||
if not isinstance(session_id, str) or not session_id.strip():
|
||||
raise MuseProtocolError("provider returned an invalid handshake response")
|
||||
return (session_created_event(self._require_config(), session_id.strip()),)
|
||||
|
||||
def _configure(self, message: str, model: str) -> tuple[str, ...]:
|
||||
if self._config is not None:
|
||||
verbose_logger.debug("Meta realtime: ignoring session.update after the Muse handshake was sent")
|
||||
return ()
|
||||
access_token: Final = self._access_token
|
||||
if access_token is None:
|
||||
raise MuseProtocolError("Meta API key was not validated before the session was configured")
|
||||
config: Final = parse_session_update(message, model)
|
||||
self._config = config
|
||||
self._transformer.configure(config)
|
||||
return (json.dumps(config.handshake(access_token), separators=(",", ":")),)
|
||||
|
||||
def _append_audio(self, request: Mapping[str, JsonValue]) -> tuple[bytes, ...]:
|
||||
config: Final = self._require_config()
|
||||
encoded: Final = request.get("audio")
|
||||
if not isinstance(encoded, str):
|
||||
raise MuseProtocolError("Audio must be a base64 string")
|
||||
if len(encoded) > config.max_encoded_append_bytes:
|
||||
raise MuseProtocolError("Audio append exceeds the four-second backlog limit")
|
||||
try:
|
||||
audio: Final = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
raise MuseProtocolError("Audio must be valid base64") from None
|
||||
if len(audio) % 2:
|
||||
raise MuseProtocolError("PCM16 audio must contain complete samples")
|
||||
buffered: Final = self._pending_audio + audio
|
||||
packet_end: Final = len(buffered) - len(buffered) % config.packet_bytes
|
||||
self._pending_audio = buffered[packet_end:]
|
||||
return tuple(
|
||||
buffered[start : start + config.packet_bytes] for start in range(0, packet_end, config.packet_bytes)
|
||||
)
|
||||
|
||||
def _flush_audio(self, *, end_stream: bool) -> tuple[str | bytes, ...]:
|
||||
remainder: Final = self._pending_audio
|
||||
self._pending_audio = b""
|
||||
frames: Final[tuple[bytes, ...]] = (remainder,) if remainder else ()
|
||||
if not end_stream or self._end_stream_sent:
|
||||
return frames
|
||||
self._end_stream_sent = True
|
||||
return (*frames, _END_STREAM)
|
||||
|
||||
def _require_config(self) -> MuseSessionConfig:
|
||||
if self._config is None:
|
||||
raise MuseProtocolError("session.update must configure the Muse session before audio is sent")
|
||||
return self._config
|
||||
|
|
@ -173,7 +173,7 @@
|
|||
"api_key_env": "META_API_KEY",
|
||||
"api_base_env": "META_API_BASE",
|
||||
"base_class": "openai_gpt",
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"]
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages", "/v1/realtime"]
|
||||
},
|
||||
"cognition": {
|
||||
"base_url": "https://api.cognition.ai/v1",
|
||||
|
|
|
|||
|
|
@ -4497,7 +4497,7 @@
|
|||
},
|
||||
"azure/eu/o1-2024-12-17": {
|
||||
"cache_read_input_token_cost": 8.25e-06,
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.65e-05,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -4543,7 +4543,7 @@
|
|||
},
|
||||
"azure/eu/o3-mini-2025-01-31": {
|
||||
"cache_read_input_token_cost": 6.05e-07,
|
||||
"deprecation_date": "2026-10-01",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.21e-06,
|
||||
"input_cost_per_token_batches": 6.05e-07,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8730,7 +8730,7 @@
|
|||
"supports_function_calling": true
|
||||
},
|
||||
"azure/o1": {
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 7.5e-06,
|
||||
"input_cost_per_token": 1.5e-05,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8748,7 +8748,7 @@
|
|||
},
|
||||
"azure/o1-2024-12-17": {
|
||||
"cache_read_input_token_cost": 7.5e-06,
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.5e-05,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -8825,7 +8825,7 @@
|
|||
"supports_vision": false
|
||||
},
|
||||
"azure/o3": {
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8855,7 +8855,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"azure/o3-2025-04-16": {
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8886,7 +8886,7 @@
|
|||
},
|
||||
"azure/o3-deep-research": {
|
||||
"cache_read_input_token_cost": 2.5e-06,
|
||||
"deprecation_date": "2026-12-26",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1e-05,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -8923,7 +8923,7 @@
|
|||
"supports_web_search": true
|
||||
},
|
||||
"azure/o3-mini": {
|
||||
"deprecation_date": "2026-10-01",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"input_cost_per_token": 1.1e-06,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8940,7 +8940,7 @@
|
|||
},
|
||||
"azure/o3-mini-2025-01-31": {
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"deprecation_date": "2026-10-01",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.1e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -8954,7 +8954,7 @@
|
|||
"supports_vision": false
|
||||
},
|
||||
"azure/o3-pro": {
|
||||
"deprecation_date": "2026-12-17",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 2e-05,
|
||||
"input_cost_per_token_batches": 1e-05,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8985,7 +8985,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"azure/o3-pro-2025-06-10": {
|
||||
"deprecation_date": "2026-12-17",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 2e-05,
|
||||
"input_cost_per_token_batches": 1e-05,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -9016,7 +9016,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"azure/o4-mini": {
|
||||
"deprecation_date": "2026-10-16",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 2.75e-07,
|
||||
"input_cost_per_token": 1.1e-06,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -9047,7 +9047,7 @@
|
|||
},
|
||||
"azure/o4-mini-2025-04-16": {
|
||||
"cache_read_input_token_cost": 2.75e-07,
|
||||
"deprecation_date": "2026-10-16",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.1e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -9600,7 +9600,7 @@
|
|||
},
|
||||
"azure/us/o1-2024-12-17": {
|
||||
"cache_read_input_token_cost": 8.25e-06,
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.65e-05,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -9645,7 +9645,7 @@
|
|||
"supports_vision": false
|
||||
},
|
||||
"azure/us/o3-2025-04-16": {
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"input_cost_per_token": 2.2e-06,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -9676,7 +9676,7 @@
|
|||
},
|
||||
"azure/us/o3-mini-2025-01-31": {
|
||||
"cache_read_input_token_cost": 6.05e-07,
|
||||
"deprecation_date": "2026-10-01",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.21e-06,
|
||||
"input_cost_per_token_batches": 6.05e-07,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -9693,7 +9693,7 @@
|
|||
},
|
||||
"azure/us/o4-mini-2025-04-16": {
|
||||
"cache_read_input_token_cost": 3.1e-07,
|
||||
"deprecation_date": "2026-10-16",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.21e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -14615,7 +14615,7 @@
|
|||
},
|
||||
"computer-use-preview": {
|
||||
"input_cost_per_token": 3e-06,
|
||||
"litellm_provider": "azure",
|
||||
"litellm_provider": "openai",
|
||||
"max_input_tokens": 8192,
|
||||
"max_output_tokens": 1024,
|
||||
"max_tokens": 1024,
|
||||
|
|
@ -14633,12 +14633,14 @@
|
|||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_response_schema": false,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"source": "https://platform.openai.com/docs/models/computer-use-preview"
|
||||
},
|
||||
"dall-e-2": {
|
||||
"deprecation_date": "2026-05-12",
|
||||
|
|
@ -34720,6 +34722,22 @@
|
|||
"supports_web_search": true,
|
||||
"supports_xhigh_reasoning_effort": true
|
||||
},
|
||||
"meta/muse-voice-transcribe-1.0": {
|
||||
"input_cost_per_second": 0.00005,
|
||||
"litellm_provider": "meta",
|
||||
"mode": "audio_transcription",
|
||||
"source": "https://dev.meta.ai/docs/speech-to-text",
|
||||
"supported_endpoints": [
|
||||
"/v1/realtime"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_input": true
|
||||
},
|
||||
"meta_llama/Llama-3.3-70B-Instruct": {
|
||||
"litellm_provider": "meta_llama",
|
||||
"max_input_tokens": 128000,
|
||||
|
|
@ -39022,7 +39040,9 @@
|
|||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"prompt_cache_min_tokens": 4096
|
||||
"prompt_cache_min_tokens": 4096,
|
||||
"supports_response_schema": true,
|
||||
"source": "https://openrouter.ai/api/v1/models"
|
||||
},
|
||||
"openrouter/anthropic/claude-sonnet-4.5": {
|
||||
"input_cost_per_image": 0.0048,
|
||||
|
|
@ -39170,18 +39190,20 @@
|
|||
},
|
||||
"openrouter/deepseek/deepseek-v3.2": {
|
||||
"input_cost_per_token": 2.69e-07,
|
||||
"input_cost_per_token_cache_hit": 2.8e-08,
|
||||
"input_cost_per_token_cache_hit": 1.345e-07,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 163840,
|
||||
"max_output_tokens": 163840,
|
||||
"max_tokens": 163840,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-07,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
"supports_tool_choice": true,
|
||||
"supports_response_schema": true,
|
||||
"source": "https://openrouter.ai/api/v1/models"
|
||||
},
|
||||
"openrouter/deepseek/deepseek-v3.2-exp": {
|
||||
"input_cost_per_token": 2.7e-07,
|
||||
|
|
@ -43491,6 +43513,7 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"together_ai/openai/gpt-oss-20b": {
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 5e-08,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -43783,6 +43806,7 @@
|
|||
"source": "https://docs.together.ai/docs/serverless-models"
|
||||
},
|
||||
"together_ai/google/gemma-4-31B-it": {
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 3.9e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 262144,
|
||||
|
|
@ -43797,6 +43821,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"together_ai/intfloat/multilingual-e5-large-instruct": {
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 2e-08,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 514,
|
||||
|
|
@ -43909,6 +43934,7 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"together_ai/thinkingmachines/Inkling-Small": {
|
||||
"deprecation_date": "2026-09-14",
|
||||
"cache_read_input_token_cost": 1e-07,
|
||||
"input_cost_per_token": 5e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -59101,9 +59127,9 @@
|
|||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
"supports_reasoning": true,
|
||||
"input_cost_per_token": 0.00000131,
|
||||
"output_cost_per_token": 0.00000396,
|
||||
"cache_read_input_token_cost": 0.000000044,
|
||||
"input_cost_per_token": 1.31e-06,
|
||||
"output_cost_per_token": 3.96e-06,
|
||||
"cache_read_input_token_cost": 4.4e-08,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
|
|
@ -59111,9 +59137,9 @@
|
|||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
"supports_reasoning": true,
|
||||
"input_cost_per_token": 0.0000001,
|
||||
"output_cost_per_token": 0.00000015,
|
||||
"cache_read_input_token_cost": 0.00000005,
|
||||
"input_cost_per_token": 1e-07,
|
||||
"output_cost_per_token": 1.5e-07,
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
|
|
@ -60835,6 +60861,7 @@
|
|||
"source": "https://docs.together.ai/docs/serverless-models"
|
||||
},
|
||||
"together_ai/moonshotai/Kimi-K2.6": {
|
||||
"deprecation_date": "2026-08-19",
|
||||
"input_cost_per_token": 1.2e-06,
|
||||
"output_cost_per_token": 4.5e-06,
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
|
|
@ -60861,6 +60888,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/zai-org/GLM-5": {
|
||||
"deprecation_date": "2026-06-22",
|
||||
"input_cost_per_token": 1e-06,
|
||||
"output_cost_per_token": 3.2e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -60869,6 +60897,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/zai-org/GLM-5.1": {
|
||||
"deprecation_date": "2026-07-10",
|
||||
"input_cost_per_token": 1.4e-06,
|
||||
"output_cost_per_token": 4.4e-06,
|
||||
"cache_read_input_token_cost": 2.6e-07,
|
||||
|
|
@ -60886,6 +60915,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/Qwen/Qwen3-Coder-Next-FP8": {
|
||||
"deprecation_date": "2026-05-14",
|
||||
"input_cost_per_token": 5e-07,
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -60894,6 +60924,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/Qwen/Qwen3-VL-32B-Instruct": {
|
||||
"deprecation_date": "2026-02-25",
|
||||
"input_cost_per_token": 5e-07,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -60902,6 +60933,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/Qwen/Qwen3-VL-8B-Instruct": {
|
||||
"deprecation_date": "2026-04-16",
|
||||
"input_cost_per_token": 1.8e-07,
|
||||
"output_cost_per_token": 6.8e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -60934,6 +60966,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/Qwen/QwQ-32B": {
|
||||
"deprecation_date": "2025-11-13",
|
||||
"input_cost_per_token": 1.2e-06,
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from litellm.llms.base_llm.ocr.transformation import (
|
|||
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
|
||||
from litellm.ocr.input import FileReader
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import CustomPricingLiteLLMParams
|
||||
from litellm.utils import ProviderConfigManager, client
|
||||
|
||||
base_llm_http_handler: Final = BaseLLMHTTPHandler()
|
||||
|
|
@ -149,6 +150,7 @@ def _prepare_ocr_request(
|
|||
litellm_params={
|
||||
"litellm_call_id": litellm_call_id,
|
||||
"api_base": resolved_api_base,
|
||||
**litellm_params.model_dump(include=frozenset(CustomPricingLiteLLMParams.model_fields), exclude_none=True),
|
||||
},
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3234,6 +3234,17 @@
|
|||
}
|
||||
],
|
||||
"title": "User Email"
|
||||
},
|
||||
"user_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "User Id"
|
||||
}
|
||||
},
|
||||
"title": "KeyMetadata",
|
||||
|
|
@ -5134,6 +5145,49 @@
|
|||
"anthropic_skills"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/skills/{skill_id}/archive": {
|
||||
"get": {
|
||||
"description": "Stored skill upload, repacked so SKILL.md sits at the archive root.",
|
||||
"operationId": "agent_skills_archive_v1_skills__skill_id__archive_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "skill_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Skill Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/zip": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Agent Skills Archive",
|
||||
"tags": [
|
||||
"anthropic_skills"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import contextlib
|
|||
import json
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine, Mapping, Sequence
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from types import MappingProxyType
|
||||
|
|
@ -3210,20 +3210,24 @@ class ProxyBaseLLMRequestProcessing:
|
|||
end-of-stream blocks complete, so the spend log sees
|
||||
guardrail_information.
|
||||
|
||||
Three closure shapes, matching who owns logging for the stream:
|
||||
Two closure shapes, matching who owns logging for the stream:
|
||||
- CustomStreamWrapper (chat completions) stores
|
||||
(assembled_response, cache_hit); the closure also runs
|
||||
non-apply_guardrail post-call hooks via
|
||||
_run_deferred_stream_guardrails.
|
||||
- Bridged /v1/responses (LiteLLMCompletionStreamingIterator) shares
|
||||
its inner CustomStreamWrapper's logging_obj, so it stores the same
|
||||
(assembled_response, cache_hit) shape; the closure only dispatches
|
||||
success logging, matching the route's pre-existing hook surface.
|
||||
- Native anthropic_messages/aresponses iterators store a single
|
||||
ready-made logging coroutine to enqueue.
|
||||
- Every other anthropic_messages/aresponses stream gets a closure
|
||||
that dispatches on the stored args shape, because the arming site
|
||||
cannot tell the producers apart: native iterators store a single
|
||||
ready-made logging coroutine to enqueue, while bridged streams
|
||||
(LiteLLMCompletionStreamingIterator, and the plain SSE generator
|
||||
AnthropicStreamWrapper returns for bridged /v1/messages) share
|
||||
their inner CustomStreamWrapper's logging_obj and so store
|
||||
(assembled_response, cache_hit); for those the closure only
|
||||
dispatches success logging, matching the route's pre-existing
|
||||
hook surface.
|
||||
|
||||
Raw async generators from passthrough routes bypass all three and
|
||||
would orphan the closure, so they are not armed here.
|
||||
Raw async generators from passthrough routes bypass both and would
|
||||
orphan the closure, so they are not armed here.
|
||||
|
||||
The router wraps iterators that cannot carry _hidden_params in
|
||||
HiddenParamsAsyncIteratorWrapper, so class sniffing runs on the
|
||||
|
|
@ -3257,31 +3261,27 @@ class ProxyBaseLLMRequestProcessing:
|
|||
if route_type not in ("anthropic_messages", "aresponses") or not self._is_streaming_response(response):
|
||||
return
|
||||
|
||||
from litellm.responses.litellm_completion_transformation.streaming_iterator import (
|
||||
LiteLLMCompletionStreamingIterator,
|
||||
)
|
||||
|
||||
if isinstance(unwrapped, LiteLLMCompletionStreamingIterator):
|
||||
_captured_bridge_logging_obj: Final = logging_obj
|
||||
|
||||
async def _on_deferred_bridged_stream_complete(assembled_response: object, cache_hit: object) -> None:
|
||||
await _as_success_dispatcher(_captured_bridge_logging_obj).dispatch_success_handlers(
|
||||
assembled_response,
|
||||
cache_hit=cache_hit,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
prefer_async_handlers=True,
|
||||
)
|
||||
|
||||
logging_obj._on_deferred_stream_complete = _on_deferred_bridged_stream_complete
|
||||
return
|
||||
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
||||
async def _on_deferred_native_stream_complete(
|
||||
logging_coroutine: Coroutine[object, object, object],
|
||||
) -> None:
|
||||
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(async_coroutine=logging_coroutine)
|
||||
_captured_native_logging_obj: Final = logging_obj
|
||||
|
||||
async def _on_deferred_native_stream_complete(*args: object) -> None:
|
||||
match args:
|
||||
case (logging_coroutine,) if asyncio.iscoroutine(logging_coroutine):
|
||||
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(async_coroutine=logging_coroutine)
|
||||
case (assembled_response, cache_hit):
|
||||
await _as_success_dispatcher(_captured_native_logging_obj).dispatch_success_handlers(
|
||||
assembled_response,
|
||||
cache_hit=cache_hit,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
prefer_async_handlers=True,
|
||||
)
|
||||
case _:
|
||||
verbose_proxy_logger.error(
|
||||
"Deferred stream logging dropped: unexpected stored args shape %s",
|
||||
tuple(type(arg).__name__ for arg in args),
|
||||
)
|
||||
|
||||
logging_obj._on_deferred_stream_complete = _on_deferred_native_stream_complete
|
||||
|
||||
|
|
|
|||
132
litellm/proxy/common_utils/config_includes.py
Normal file
132
litellm/proxy/common_utils/config_includes.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import os
|
||||
from collections.abc import Awaitable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
INCLUDE_KEY: Final = "include"
|
||||
|
||||
|
||||
def resolve_include_file_path(include_file: str, declared_in: str, root_config_path: str) -> str:
|
||||
"""
|
||||
Resolve one `include` entry to the file it names, next to the config that declares it.
|
||||
|
||||
A config written before nested entries resolved this way can name a file sitting next to the root
|
||||
config instead, so that file is still read, with a warning naming where it was found. When both
|
||||
files exist the one next to the declaring config wins and the other is named in a warning.
|
||||
"""
|
||||
declared_relative: Final = os.path.abspath(os.path.join(os.path.dirname(declared_in), include_file))
|
||||
root_relative: Final = os.path.abspath(os.path.join(os.path.dirname(root_config_path), include_file))
|
||||
if root_relative == declared_relative or not os.path.exists(root_relative):
|
||||
return declared_relative
|
||||
|
||||
if not os.path.exists(declared_relative):
|
||||
verbose_proxy_logger.warning(
|
||||
"Config include '%s' declared in %s was not found next to it, so %s was read instead. "
|
||||
"Move the included file next to the config that declares it.",
|
||||
include_file,
|
||||
declared_in,
|
||||
root_relative,
|
||||
)
|
||||
return root_relative
|
||||
|
||||
verbose_proxy_logger.warning(
|
||||
"Config include '%s' declared in %s matches two files. %s sits next to that config and was read, "
|
||||
"so %s was skipped. Rename one of the two to say which one you meant.",
|
||||
include_file,
|
||||
declared_in,
|
||||
declared_relative,
|
||||
root_relative,
|
||||
)
|
||||
return declared_relative
|
||||
|
||||
|
||||
class IncludeResolver(Protocol):
|
||||
def __call__(self, include_entry: str, declared_in: str, /) -> str: ...
|
||||
|
||||
|
||||
class ConfigReader(Protocol):
|
||||
def __call__(self, location: str, /) -> Awaitable[Mapping[str, object]]: ...
|
||||
|
||||
|
||||
def _merged_value(base_value: object, included_value: object) -> object:
|
||||
if isinstance(included_value, list) and isinstance(base_value, list):
|
||||
return [*base_value, *included_value] # mutable-ok: a merged config value stays the plain list the proxy loads
|
||||
return included_value
|
||||
|
||||
|
||||
def _merged_entry(base: Mapping[str, object], included: Mapping[str, object], key: str) -> object:
|
||||
if key not in included:
|
||||
return base[key]
|
||||
return _merged_value(base.get(key), included[key])
|
||||
|
||||
|
||||
def _merged(base: Mapping[str, object], included: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return MappingProxyType({key: _merged_entry(base, included, key) for key in (*base, *included)})
|
||||
|
||||
|
||||
def _without_include(config: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return MappingProxyType({key: value for key, value in config.items() if key != INCLUDE_KEY})
|
||||
|
||||
|
||||
def include_entries(config: Mapping[str, object]) -> tuple[str, ...]:
|
||||
if INCLUDE_KEY not in config:
|
||||
return ()
|
||||
|
||||
entries: Final = config[INCLUDE_KEY]
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError("'include' must be a list of file paths")
|
||||
|
||||
paths: Final = tuple(entry for entry in entries if isinstance(entry, str))
|
||||
if len(paths) != len(entries):
|
||||
raise ValueError("'include' must be a list of file paths")
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def _pending_from(config: Mapping[str, object], location: str) -> tuple[tuple[str, str], ...]:
|
||||
return tuple((entry, location) for entry in include_entries(config))
|
||||
|
||||
|
||||
async def _resolve(
|
||||
config: Mapping[str, object],
|
||||
pending: tuple[tuple[str, str], ...],
|
||||
loaded: frozenset[str],
|
||||
resolve: IncludeResolver,
|
||||
read: ConfigReader,
|
||||
) -> Mapping[str, object]:
|
||||
if not pending:
|
||||
return _without_include(config)
|
||||
|
||||
entry, declared_in = pending[0]
|
||||
location: Final = resolve(entry, declared_in)
|
||||
if location in loaded:
|
||||
return await _resolve(config, pending[1:], loaded, resolve, read)
|
||||
|
||||
included: Final = await read(location)
|
||||
return await _resolve(
|
||||
_merged(config, _without_include(included)),
|
||||
(*pending[1:], *_pending_from(included, location)),
|
||||
loaded | frozenset((location,)),
|
||||
resolve,
|
||||
read,
|
||||
)
|
||||
|
||||
|
||||
async def resolve_includes(
|
||||
config: Mapping[str, object],
|
||||
location: str,
|
||||
resolve: IncludeResolver,
|
||||
read: ConfigReader,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Merge every config named by the `include` directive into the config that declares it.
|
||||
|
||||
List values are extended and every other value is overridden, `resolve` turns each entry into the
|
||||
location it names relative to the config that declares it, a config already pulled in is neither
|
||||
read nor merged a second time, and `read` decides where a location is read from, so the same merge
|
||||
applies to configs on disk and to configs hosted in a bucket.
|
||||
"""
|
||||
merged: Final = await _resolve(config, _pending_from(config, location), frozenset((location,)), resolve, read)
|
||||
return dict(merged) # mutable-ok: the proxy mutates the config it loads
|
||||
|
|
@ -1,12 +1,47 @@
|
|||
import asyncio
|
||||
import os
|
||||
from typing import Final
|
||||
import posixpath
|
||||
from collections.abc import Awaitable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Protocol
|
||||
|
||||
import yaml
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy.common_utils.config_includes import resolve_includes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase
|
||||
|
||||
_BUCKET_CONFIG_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
def get_file_contents_from_s3(bucket_name, object_key):
|
||||
class BucketObjectFetcher(Protocol):
|
||||
def __call__(self, object_key: str, /) -> Awaitable[Mapping[str, object] | None]: ...
|
||||
|
||||
|
||||
class BucketObjectReader(Protocol):
|
||||
def __call__(self, object_key: str, /) -> Awaitable[object | None]: ...
|
||||
|
||||
|
||||
class SyncBucketObjectReader(Protocol):
|
||||
def __call__(self, object_key: str, /) -> object | None: ...
|
||||
|
||||
|
||||
def _parsed_config(object_key: str, file_contents: str) -> object | None:
|
||||
try:
|
||||
parsed: Final = yaml.safe_load(file_contents)
|
||||
except yaml.YAMLError as e:
|
||||
verbose_proxy_logger.error("Config object %s is not valid YAML: %s", object_key, e)
|
||||
return None
|
||||
return MappingProxyType({}) if parsed is None else parsed
|
||||
|
||||
|
||||
def s3_object_reader(bucket_name: str) -> SyncBucketObjectReader:
|
||||
"""
|
||||
Build one reader for a whole config, so an `include` tree costs one S3 client rather than one per object.
|
||||
"""
|
||||
try:
|
||||
# v0 rely on boto3 for authentication - allowing boto3 to handle IAM credentials etc
|
||||
import boto3
|
||||
|
|
@ -21,46 +56,147 @@ def get_file_contents_from_s3(bucket_name, object_key):
|
|||
aws_secret_access_key=credentials.secret_key,
|
||||
aws_session_token=credentials.token, # Optional, if using temporary credentials
|
||||
)
|
||||
verbose_proxy_logger.debug("Retrieving %s from S3 bucket: %s", object_key, bucket_name)
|
||||
response: Final = s3_client.get_object(Bucket=bucket_name, Key=object_key)
|
||||
verbose_proxy_logger.debug("Response: %s", response)
|
||||
|
||||
# Read the file contents and directly parse YAML
|
||||
file_contents: Final = response["Body"].read().decode("utf-8")
|
||||
verbose_proxy_logger.debug("File contents retrieved from S3")
|
||||
|
||||
# Parse YAML directly from string
|
||||
config: Final = yaml.safe_load(file_contents)
|
||||
return config
|
||||
|
||||
except ImportError as e:
|
||||
# this is most likely if a user is not using the litellm docker container
|
||||
verbose_proxy_logger.error("ImportError: %s", e)
|
||||
return lambda object_key: None
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("Error retrieving file contents: %s", e)
|
||||
verbose_proxy_logger.error("Error creating the S3 client for bucket %s: %s", bucket_name, e)
|
||||
return lambda object_key: None
|
||||
|
||||
def read(object_key: str) -> object | None:
|
||||
try:
|
||||
verbose_proxy_logger.debug("Retrieving %s from S3 bucket: %s", object_key, bucket_name)
|
||||
response: Final = s3_client.get_object(Bucket=bucket_name, Key=object_key)
|
||||
file_contents: Final = response["Body"].read().decode("utf-8")
|
||||
except Exception as e: # noqa: BLE001 # any boto3 error must read as a missing object
|
||||
verbose_proxy_logger.error("Error retrieving %s from S3 bucket %s: %s", object_key, bucket_name, e)
|
||||
return None
|
||||
|
||||
return _parsed_config(object_key, file_contents)
|
||||
|
||||
return read
|
||||
|
||||
|
||||
def get_file_contents_from_s3(bucket_name: str, object_key: str) -> object | None:
|
||||
return s3_object_reader(bucket_name)(object_key)
|
||||
|
||||
|
||||
def gcs_config_bucket(bucket_name: str) -> "GCSBucketBase | None":
|
||||
"""
|
||||
Build a plain GCS client for reading config objects.
|
||||
|
||||
Reading a config out of a bucket is not GCS logging, so it neither needs the enterprise license
|
||||
that gate covers nor the batching task the logger starts and never stops.
|
||||
"""
|
||||
try:
|
||||
from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase
|
||||
|
||||
return GCSBucketBase(bucket_name=bucket_name)
|
||||
except Exception as e: # noqa: BLE001 # an unbuildable client must read as an unreadable bucket
|
||||
verbose_proxy_logger.error("Error creating the GCS client for bucket %s: %s", bucket_name, e)
|
||||
return None
|
||||
|
||||
|
||||
async def get_config_file_contents_from_gcs(bucket_name, object_key):
|
||||
async def get_config_file_contents_from_gcs(
|
||||
bucket_name: str,
|
||||
object_key: str,
|
||||
gcs_bucket: "GCSBucketBase | None" = None,
|
||||
) -> object | None:
|
||||
try:
|
||||
from litellm.integrations.gcs_bucket.gcs_bucket import GCSBucketLogger
|
||||
|
||||
gcs_bucket: Final = GCSBucketLogger(
|
||||
bucket_name=bucket_name,
|
||||
)
|
||||
file_contents = await gcs_bucket.download_gcs_object(object_key)
|
||||
bucket: Final = gcs_config_bucket(bucket_name) if gcs_bucket is None else gcs_bucket
|
||||
if bucket is None:
|
||||
return None
|
||||
file_contents: Final = await bucket.download_gcs_object(object_key)
|
||||
if file_contents is None:
|
||||
raise Exception(f"File contents are None for {object_key}")
|
||||
# file_contentis is a bytes object, so we need to convert it to yaml
|
||||
file_contents = file_contents.decode("utf-8")
|
||||
# convert to yaml
|
||||
config: Final = yaml.safe_load(file_contents)
|
||||
return config
|
||||
decoded: Final = file_contents.decode("utf-8")
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("Error retrieving file contents: %s", e)
|
||||
verbose_proxy_logger.error("Error retrieving %s from GCS bucket %s: %s", object_key, bucket_name, e)
|
||||
return None
|
||||
|
||||
return _parsed_config(object_key, decoded)
|
||||
|
||||
|
||||
def resolve_include_object_key(config_object_key: str, include_entry: str) -> str:
|
||||
"""
|
||||
Resolve one `include` entry to the object key it names, relative to the config object's prefix.
|
||||
|
||||
A leading "/" means the bucket root, mirroring how an absolute path on disk ignores the
|
||||
directory the including config sits in.
|
||||
"""
|
||||
if include_entry.startswith("/"):
|
||||
return posixpath.normpath(include_entry).lstrip("/")
|
||||
return posixpath.normpath(posixpath.join(posixpath.dirname(config_object_key), include_entry))
|
||||
|
||||
|
||||
async def resolve_bucket_includes(
|
||||
*,
|
||||
config: Mapping[str, object],
|
||||
object_key: str,
|
||||
fetch: BucketObjectFetcher,
|
||||
) -> dict[str, object]:
|
||||
async def read(include_key: str) -> Mapping[str, object]:
|
||||
included: Final = await fetch(include_key)
|
||||
if included is None:
|
||||
raise FileNotFoundError(
|
||||
f"Included config could not be read from bucket: {include_key}. "
|
||||
"The underlying bucket error is logged above."
|
||||
)
|
||||
return included
|
||||
|
||||
def resolve(include_entry: str, declared_in: str) -> str:
|
||||
return resolve_include_object_key(declared_in, include_entry)
|
||||
|
||||
return await resolve_includes(config=config, location=object_key, resolve=resolve, read=read)
|
||||
|
||||
|
||||
async def bucket_object_reader(bucket_type: str | None, bucket_name: str) -> BucketObjectReader:
|
||||
"""
|
||||
Build one reader for a whole config, so an `include` tree costs one bucket client rather than one per object.
|
||||
"""
|
||||
if bucket_type != "gcs":
|
||||
read_object: Final = await asyncio.to_thread(s3_object_reader, bucket_name)
|
||||
|
||||
async def read_from_s3(object_key: str) -> object | None:
|
||||
return await asyncio.to_thread(read_object, object_key)
|
||||
|
||||
return read_from_s3
|
||||
|
||||
gcs_bucket: Final = gcs_config_bucket(bucket_name)
|
||||
|
||||
async def read_from_gcs(object_key: str) -> object | None:
|
||||
if gcs_bucket is None:
|
||||
return None
|
||||
return await get_config_file_contents_from_gcs(bucket_name, object_key, gcs_bucket)
|
||||
|
||||
return read_from_gcs
|
||||
|
||||
|
||||
async def get_config_from_bucket(
|
||||
*,
|
||||
bucket_type: str | None,
|
||||
bucket_name: str,
|
||||
object_key: str,
|
||||
) -> dict[str, object] | None:
|
||||
read: Final = await bucket_object_reader(bucket_type, bucket_name)
|
||||
|
||||
async def fetch(key: str) -> Mapping[str, object] | None:
|
||||
raw: Final = await read(key)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return _BUCKET_CONFIG_ADAPTER.validate_python(raw)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"Config object in bucket is not a YAML mapping: {key}") from e
|
||||
|
||||
config: Final = await fetch(object_key)
|
||||
if not config:
|
||||
return None
|
||||
|
||||
return await resolve_bucket_includes(config=config, object_key=object_key, fetch=fetch)
|
||||
|
||||
|
||||
def download_python_file_from_s3(
|
||||
bucket_name: str,
|
||||
|
|
@ -136,11 +272,9 @@ async def download_python_file_from_gcs(
|
|||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
from litellm.integrations.gcs_bucket.gcs_bucket import GCSBucketLogger
|
||||
from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase
|
||||
|
||||
gcs_bucket: Final = GCSBucketLogger(
|
||||
bucket_name=bucket_name,
|
||||
)
|
||||
gcs_bucket: Final = GCSBucketBase(bucket_name=bucket_name)
|
||||
file_contents = await gcs_bucket.download_gcs_object(object_key)
|
||||
if file_contents is None:
|
||||
raise Exception(f"File contents are None for {object_key}")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from .agent_skills_endpoints import router as agent_skills_discovery_router
|
||||
from .ui_discovery_endpoints import router as ui_discovery_endpoints_router
|
||||
|
||||
__all__ = ["ui_discovery_endpoints_router"]
|
||||
__all__ = ["agent_skills_discovery_router", "ui_discovery_endpoints_router"]
|
||||
|
|
|
|||
130
litellm/proxy/discovery_endpoints/agent_skills_archive.py
Normal file
130
litellm/proxy/discovery_endpoints/agent_skills_archive.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Repack a stored skill upload into the archive shape Agent Skills clients install from.
|
||||
|
||||
Uploads follow the Anthropic Skills API layout, where every file sits under a single
|
||||
top-level folder. Discovery clients read ``SKILL.md`` from the archive root, so that
|
||||
folder is stripped and the zip is rebuilt with fixed entry timestamps, which keeps the
|
||||
SHA-256 digest published in the index reproducible for identical uploads.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import yaml
|
||||
|
||||
MAX_ARCHIVE_UNPACKED_BYTES: Final = 50 * 1024 * 1024
|
||||
MAX_ARCHIVE_ENTRIES: Final = 1000
|
||||
SKILL_MANIFEST_FILENAME: Final = "SKILL.md"
|
||||
|
||||
_ZIP_ENTRY_TIMESTAMP: Final = (1980, 1, 1, 0, 0, 0)
|
||||
_ZIP_ENTRY_PERMISSIONS: Final = 0o644 << 16
|
||||
_FRONTMATTER_PATTERN: Final = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.DOTALL)
|
||||
_WINDOWS_DRIVE_PATTERN: Final = re.compile(r"^[A-Za-z]:")
|
||||
_EMPTY_FRONTMATTER: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillArchive:
|
||||
content: bytes
|
||||
digest: str
|
||||
declared_name: str | None
|
||||
declared_description: str | None
|
||||
|
||||
|
||||
def build_skill_archive(stored_content: bytes) -> SkillArchive | None:
|
||||
"""Return the installable archive for an upload, or None when it holds no root SKILL.md."""
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(stored_content)) as uploaded:
|
||||
members: Final = _flattened_members(uploaded)
|
||||
except (zipfile.BadZipFile, OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
if members is None:
|
||||
return None
|
||||
|
||||
frontmatter: Final = _manifest_frontmatter(next(data for name, data in members if name == SKILL_MANIFEST_FILENAME))
|
||||
content: Final = _repack(members)
|
||||
return SkillArchive(
|
||||
content=content,
|
||||
digest=f"sha256:{hashlib.sha256(content).hexdigest()}",
|
||||
declared_name=_frontmatter_text(frontmatter, "name"),
|
||||
declared_description=_frontmatter_text(frontmatter, "description"),
|
||||
)
|
||||
|
||||
|
||||
def _flattened_members(uploaded: zipfile.ZipFile) -> tuple[tuple[str, bytes], ...] | None:
|
||||
infos: Final = tuple(info for info in uploaded.infolist() if not info.is_dir())
|
||||
if not infos or len(infos) > MAX_ARCHIVE_ENTRIES:
|
||||
return None
|
||||
if sum(info.file_size for info in infos) > MAX_ARCHIVE_UNPACKED_BYTES:
|
||||
return None
|
||||
|
||||
normalized: Final = tuple((info, _normalized_path(info.filename)) for info in infos)
|
||||
if any(path is None for _, path in normalized):
|
||||
return None
|
||||
|
||||
prefix: Final = _common_root_prefix(tuple(path for _, path in normalized if path is not None))
|
||||
flattened: Final = tuple((info, path[len(prefix) :]) for info, path in normalized if path is not None)
|
||||
names: Final = frozenset(name for _, name in flattened)
|
||||
if SKILL_MANIFEST_FILENAME not in names or len(names) != len(flattened):
|
||||
return None
|
||||
|
||||
return tuple((name, uploaded.read(info)) for info, name in sorted(flattened, key=lambda member: member[1]))
|
||||
|
||||
|
||||
def _common_root_prefix(paths: tuple[str, ...]) -> str:
|
||||
roots: Final = frozenset(path.split("/", 1)[0] for path in paths)
|
||||
if len(roots) != 1 or not all("/" in path for path in paths):
|
||||
return ""
|
||||
return f"{next(iter(roots))}/"
|
||||
|
||||
|
||||
def _normalized_path(raw_path: str) -> str | None:
|
||||
if not raw_path or "\0" in raw_path or "\\" in raw_path:
|
||||
return None
|
||||
if raw_path.startswith("/") or _WINDOWS_DRIVE_PATTERN.match(raw_path):
|
||||
return None
|
||||
parts: Final = tuple(part for part in raw_path.split("/") if part)
|
||||
if not parts or any(part in (".", "..") for part in parts):
|
||||
return None
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _manifest_frontmatter(manifest: bytes) -> Mapping[str, object]:
|
||||
match: Final = _FRONTMATTER_PATTERN.match(manifest.decode("utf-8", errors="replace"))
|
||||
if match is None:
|
||||
return _EMPTY_FRONTMATTER
|
||||
try:
|
||||
parsed: Final = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return _EMPTY_FRONTMATTER
|
||||
if not isinstance(parsed, dict):
|
||||
return _EMPTY_FRONTMATTER
|
||||
return parsed
|
||||
|
||||
|
||||
def _frontmatter_text(frontmatter: Mapping[str, object], key: str) -> str | None:
|
||||
value: Final = frontmatter.get(key)
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
def _zip_entry(name: str) -> zipfile.ZipInfo:
|
||||
entry: Final = zipfile.ZipInfo(filename=name, date_time=_ZIP_ENTRY_TIMESTAMP)
|
||||
entry.compress_type = zipfile.ZIP_DEFLATED
|
||||
entry.external_attr = _ZIP_ENTRY_PERMISSIONS
|
||||
return entry
|
||||
|
||||
|
||||
def _repack(members: tuple[tuple[str, bytes], ...]) -> bytes:
|
||||
buffer: Final = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as repacked:
|
||||
for name, data in members:
|
||||
repacked.writestr(_zip_entry(name), data)
|
||||
return buffer.getvalue()
|
||||
200
litellm/proxy/discovery_endpoints/agent_skills_endpoints.py
Normal file
200
litellm/proxy/discovery_endpoints/agent_skills_endpoints.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""Serve skills stored on the proxy as an Agent Skills well-known discovery index.
|
||||
|
||||
``npx skills add <proxy url> -a <agent>`` reads ``/.well-known/agent-skills/index.json``
|
||||
and downloads each entry's archive. Discovery clients send no credentials, so both
|
||||
routes are unauthenticated and stay off until ``litellm_settings.public_skills_index``
|
||||
is enabled, which publishes every stored skill to anyone who can reach the proxy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from itertools import groupby
|
||||
from operator import itemgetter
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.models.skills import LiteLLM_SkillsTable
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import SkillArchive, build_skill_archive
|
||||
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import (
|
||||
MAX_SKILL_DESCRIPTION_LENGTH,
|
||||
MAX_SKILL_NAME_LENGTH,
|
||||
AgentSkillsIndex,
|
||||
AgentSkillsIndexEntry,
|
||||
)
|
||||
|
||||
MAX_INDEXED_SKILLS: Final = 1000
|
||||
MAX_CACHED_ARCHIVES: Final = 128
|
||||
MAX_CACHED_ARCHIVE_BYTES: Final = 512 * 1024
|
||||
ARCHIVE_CACHE_TTL_SECONDS: Final = 3600
|
||||
|
||||
_ARCHIVE_CACHE: Final = InMemoryCache(
|
||||
max_size_in_memory=MAX_CACHED_ARCHIVES,
|
||||
default_ttl=ARCHIVE_CACHE_TTL_SECONDS,
|
||||
max_size_per_item=MAX_CACHED_ARCHIVE_BYTES // 1024,
|
||||
)
|
||||
|
||||
_NON_SLUG_PATTERN: Final = re.compile(r"[^a-z0-9]+")
|
||||
_FALLBACK_SKILL_NAME: Final = "skill"
|
||||
|
||||
router: Final = APIRouter(tags=["public", "skills"]) # mutable-ok: fastapi types tags as list[str | Enum]
|
||||
|
||||
|
||||
class ZipArchiveResponse(Response):
|
||||
"""Response whose OpenAPI entry declares an application/zip download rather than JSON."""
|
||||
|
||||
media_type = "application/zip"
|
||||
|
||||
|
||||
def ensure_index_enabled() -> None:
|
||||
if litellm.public_skills_index is not True:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
|
||||
async def stored_skills() -> Sequence[LiteLLM_SkillsTable]:
|
||||
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
|
||||
|
||||
return await LiteLLMSkillsHandler.list_skills(limit=MAX_INDEXED_SKILLS)
|
||||
|
||||
|
||||
async def stored_skill(skill_id: str) -> LiteLLM_SkillsTable | None:
|
||||
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
|
||||
|
||||
try:
|
||||
return await LiteLLMSkillsHandler.get_skill(skill_id)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/.well-known/agent-skills/index.json",
|
||||
response_model=AgentSkillsIndex,
|
||||
dependencies=(Depends(ensure_index_enabled),),
|
||||
)
|
||||
@router.get(
|
||||
"/.well-known/skills/index.json",
|
||||
response_model=AgentSkillsIndex,
|
||||
dependencies=(Depends(ensure_index_enabled),),
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def agent_skills_index(
|
||||
request: Request,
|
||||
skills: Sequence[LiteLLM_SkillsTable] = Depends(stored_skills),
|
||||
) -> AgentSkillsIndex:
|
||||
"""Agent Skills v0.2.0 discovery index over every skill stored on this proxy."""
|
||||
from litellm.proxy.utils import get_custom_url
|
||||
|
||||
installable: Final = await _installable(skills)
|
||||
names: Final = _deduplicated(tuple(_base_name(skill, archive) for skill, archive in installable))
|
||||
|
||||
return AgentSkillsIndex(
|
||||
skills=tuple(
|
||||
AgentSkillsIndexEntry(
|
||||
name=name,
|
||||
type="archive",
|
||||
description=_description(skill, archive, name),
|
||||
url=get_custom_url(
|
||||
request_base_url=str(request.base_url),
|
||||
route=f"v1/skills/{skill.skill_id}/archive",
|
||||
),
|
||||
digest=archive.digest,
|
||||
)
|
||||
for (skill, archive), name in zip(installable, names, strict=True)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/v1/skills/{skill_id}/archive",
|
||||
dependencies=(Depends(ensure_index_enabled),),
|
||||
response_class=ZipArchiveResponse,
|
||||
)
|
||||
async def agent_skills_archive(
|
||||
skill_id: str,
|
||||
skill: LiteLLM_SkillsTable | None = Depends(stored_skill),
|
||||
) -> ZipArchiveResponse:
|
||||
"""Stored skill upload, repacked so SKILL.md sits at the archive root."""
|
||||
archive: Final = await _archive_for(skill) if skill is not None else None
|
||||
if archive is None:
|
||||
raise HTTPException(status_code=404, detail=f"No installable skill archive for: {skill_id}")
|
||||
|
||||
return ZipArchiveResponse(
|
||||
content=archive.content,
|
||||
headers=MappingProxyType({"Content-Disposition": f'attachment; filename="{skill_id}.zip"'}),
|
||||
)
|
||||
|
||||
|
||||
async def _installable(
|
||||
skills: Sequence[LiteLLM_SkillsTable],
|
||||
) -> tuple[tuple[LiteLLM_SkillsTable, SkillArchive], ...]:
|
||||
built: Final = tuple([(skill, await _archive_for(skill)) for skill in reversed(skills)])
|
||||
return tuple((skill, archive) for skill, archive in built if archive is not None)
|
||||
|
||||
|
||||
async def _archive_for(skill: LiteLLM_SkillsTable) -> SkillArchive | None:
|
||||
if skill.file_content is None:
|
||||
return None
|
||||
|
||||
cache_key: Final = None if skill.updated_at is None else f"{skill.skill_id}:{skill.updated_at.isoformat()}"
|
||||
cached: Final = None if cache_key is None else _ARCHIVE_CACHE.get_cache(cache_key)
|
||||
if isinstance(cached, SkillArchive):
|
||||
return cached
|
||||
|
||||
archive: Final = await asyncio.to_thread(build_skill_archive, skill.file_content)
|
||||
if archive is None:
|
||||
verbose_proxy_logger.warning(
|
||||
"Agent Skills index: skipping skill %s, its upload is not a zip holding SKILL.md at the root of a "
|
||||
"single top-level folder",
|
||||
skill.skill_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if cache_key is not None and len(archive.content) <= MAX_CACHED_ARCHIVE_BYTES:
|
||||
_ARCHIVE_CACHE.set_cache(cache_key, archive)
|
||||
return archive
|
||||
|
||||
|
||||
def _base_name(skill: LiteLLM_SkillsTable, archive: SkillArchive) -> str:
|
||||
candidates: Final = (archive.declared_name, skill.display_title, skill.skill_id)
|
||||
return next(
|
||||
(slug for slug in (_slugify(candidate) for candidate in candidates) if slug is not None),
|
||||
_FALLBACK_SKILL_NAME,
|
||||
)
|
||||
|
||||
|
||||
def _slugify(raw: str | None) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
return _NON_SLUG_PATTERN.sub("-", raw.lower()).strip("-")[:MAX_SKILL_NAME_LENGTH].rstrip("-") or None
|
||||
|
||||
|
||||
def _deduplicated(names: Sequence[str]) -> tuple[str, ...]:
|
||||
ordinals: Final = MappingProxyType(
|
||||
{
|
||||
position: ordinal
|
||||
for _, duplicates in groupby(sorted(enumerate(names), key=itemgetter(1)), key=itemgetter(1))
|
||||
for ordinal, (position, _) in enumerate(duplicates)
|
||||
}
|
||||
)
|
||||
return tuple(_with_ordinal(name, ordinals[position]) for position, name in enumerate(names))
|
||||
|
||||
|
||||
def _with_ordinal(name: str, ordinal: int) -> str:
|
||||
if ordinal == 0:
|
||||
return name
|
||||
suffix: Final = f"-{ordinal + 1}"
|
||||
return f"{name[: MAX_SKILL_NAME_LENGTH - len(suffix)].rstrip('-')}{suffix}"
|
||||
|
||||
|
||||
def _description(skill: LiteLLM_SkillsTable, archive: SkillArchive, name: str) -> str:
|
||||
candidates: Final = (archive.declared_description, skill.description, skill.display_title)
|
||||
chosen: Final = next(
|
||||
(candidate.strip() for candidate in candidates if candidate is not None and candidate.strip()),
|
||||
name,
|
||||
)
|
||||
return chosen[:MAX_SKILL_DESCRIPTION_LENGTH]
|
||||
|
|
@ -341,6 +341,7 @@ class AzureContentSafetyPromptShieldGuardrail(AzureGuardrailBase, CustomGuardrai
|
|||
guardrail_response: Final = self._summarize_guardrail_response(
|
||||
response=response,
|
||||
original_inputs=original_inputs,
|
||||
event_type=event_type,
|
||||
)
|
||||
self.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_json_response=guardrail_response,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ All /budget management endpoints
|
|||
#### BUDGET TABLE MANAGEMENT ####
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
|
@ -176,6 +177,10 @@ async def update_budget(
|
|||
recomputed_reset_at: Final = (
|
||||
{"budget_reset_at": get_budget_reset_time(budget_duration=budget_obj.budget_duration)}
|
||||
if budget_obj.budget_duration is not None and "budget_reset_at" not in budget_obj.model_fields_set
|
||||
else MappingProxyType({"budget_reset_at": None})
|
||||
if "budget_duration" in budget_obj.model_fields_set
|
||||
and budget_obj.budget_duration is None
|
||||
and "budget_reset_at" not in budget_obj.model_fields_set
|
||||
else {}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ def _key_metadata(api_key_metadata: Mapping[str, _KeyMetadataDict], api_key: str
|
|||
return KeyMetadata(
|
||||
key_alias=meta.get("key_alias"),
|
||||
team_id=meta.get("team_id"),
|
||||
user_id=meta.get("user_id"),
|
||||
user_email=meta.get("user_email"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1254,8 +1254,8 @@ def _update_internal_user_params(data_json: dict, data: UpdateUserRequest | Upda
|
|||
fields_set: Final = data.fields_set() if hasattr(data, "fields_set") else set()
|
||||
|
||||
for k, v in data_json.items():
|
||||
if k == "max_budget":
|
||||
if "max_budget" in fields_set:
|
||||
if k in ("max_budget", "budget_duration"):
|
||||
if k in fields_set:
|
||||
non_default_values[k] = v
|
||||
elif k == "model_max_budget":
|
||||
if k in fields_set:
|
||||
|
|
@ -1283,8 +1283,10 @@ def _update_internal_user_params(data_json: dict, data: UpdateUserRequest | Upda
|
|||
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
|
||||
|
||||
validate_budget_duration(non_default_values["budget_duration"])
|
||||
non_default_values["budget_reset_at"] = get_budget_reset_time(
|
||||
budget_duration=non_default_values["budget_duration"]
|
||||
non_default_values["budget_reset_at"] = (
|
||||
get_budget_reset_time(budget_duration=non_default_values["budget_duration"])
|
||||
if non_default_values["budget_duration"] is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if "max_budget" not in non_default_values:
|
||||
|
|
|
|||
|
|
@ -438,6 +438,7 @@ async def update_tag(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
prisma_client=prisma_client,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
budget_duration_cleared="budget_duration" in tag.model_fields_set and tag.budget_duration is None,
|
||||
)
|
||||
|
||||
# Get model names for model_info
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from collections.abc import Callable, Mapping, MutableMapping, Sequence
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final, Protocol
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
|
@ -180,6 +181,7 @@ async def handle_budget_for_entity(
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
prisma_client: PrismaClient,
|
||||
litellm_proxy_admin_name: str,
|
||||
budget_duration_cleared: bool = False,
|
||||
) -> str | None:
|
||||
"""
|
||||
Common helper to handle budget creation/updates for entities (organizations, tags, etc).
|
||||
|
|
@ -208,7 +210,14 @@ async def handle_budget_for_entity(
|
|||
|
||||
# Extract budget fields from data
|
||||
_json_data: Final = data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data
|
||||
_budget_data: Final = {k: v for k, v in _json_data.items() if k in budget_params}
|
||||
_budget_data: Final = MappingProxyType(
|
||||
{
|
||||
k: _json_data.get(k)
|
||||
for k in budget_params
|
||||
if k in _json_data
|
||||
or (k == "budget_duration" and existing_budget_id is not None and budget_duration_cleared)
|
||||
}
|
||||
)
|
||||
|
||||
# Check if budget_id is explicitly provided in the data
|
||||
data_budget_id: Final[str | None] = getattr(data, "budget_id", None)
|
||||
|
|
|
|||
|
|
@ -216,11 +216,14 @@ class AnthropicPassthroughLoggingHandler:
|
|||
model=model,
|
||||
speed=AnthropicPassthroughLoggingHandler._cost_relevant_speed(request_body),
|
||||
)
|
||||
if response is None:
|
||||
return None
|
||||
AnthropicPassthroughLoggingHandler._recover_interrupted_stream_output_tokens(
|
||||
if not isinstance(response, ModelResponse):
|
||||
return response
|
||||
recovered_usage: Final = AnthropicPassthroughLoggingHandler._recover_interrupted_stream_output_tokens(
|
||||
response=response, all_chunks=all_chunks, model=model
|
||||
)
|
||||
if recovered_usage is None:
|
||||
return response
|
||||
AnthropicPassthroughLoggingHandler._clear_placeholder_cost(response=response, usage=recovered_usage)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -259,7 +262,9 @@ class AnthropicPassthroughLoggingHandler:
|
|||
)
|
||||
except Exception as e: # noqa: BLE001 # an uncostable partial stream still bills its tokens, at zero cost
|
||||
verbose_proxy_logger.warning(
|
||||
"Anthropic passthrough: could not cost the partial usage of a failed stream (model=%s): %s", model, e
|
||||
"Anthropic passthrough: could not cost the partial usage of an interrupted stream (model=%s): %s",
|
||||
model,
|
||||
e,
|
||||
)
|
||||
return 0.0
|
||||
|
||||
|
|
@ -359,7 +364,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
response: ModelResponse | TextCompletionResponse,
|
||||
all_chunks: Sequence[str | bytes],
|
||||
model: str,
|
||||
) -> None:
|
||||
) -> Usage | None:
|
||||
"""
|
||||
An Anthropic stream interrupted before its terminal ``message_delta``
|
||||
(client disconnect) carries only the ``message_start`` ``output_tokens``
|
||||
|
|
@ -369,24 +374,24 @@ class AnthropicPassthroughLoggingHandler:
|
|||
untouched because their terminal ``message_delta`` short-circuits here.
|
||||
"""
|
||||
if not isinstance(response, ModelResponse):
|
||||
return
|
||||
return None
|
||||
if not AnthropicPassthroughLoggingHandler._stream_was_interrupted(all_chunks):
|
||||
return
|
||||
return None
|
||||
usage: Final = getattr(response, "usage", None)
|
||||
if usage is None:
|
||||
return
|
||||
if not isinstance(usage, Usage):
|
||||
return None
|
||||
output_text: Final = get_content_from_model_response(response)
|
||||
if not output_text:
|
||||
return
|
||||
return None
|
||||
try:
|
||||
recovered_output_tokens = litellm.token_counter(model=model, text=output_text, count_response_tokens=True)
|
||||
except Exception:
|
||||
verbose_proxy_logger.warning(
|
||||
"Could not re-tokenize interrupted stream output; keeping placeholder completion token count."
|
||||
)
|
||||
return
|
||||
return None
|
||||
if recovered_output_tokens <= (usage.completion_tokens or 0):
|
||||
return
|
||||
return None
|
||||
usage.completion_tokens = recovered_output_tokens
|
||||
usage.total_tokens = (usage.prompt_tokens or 0) + recovered_output_tokens
|
||||
# Anthropic costing reads completion_tokens_details.text_tokens, so the
|
||||
|
|
@ -395,6 +400,12 @@ class AnthropicPassthroughLoggingHandler:
|
|||
details: Final = getattr(usage, "completion_tokens_details", None)
|
||||
if details is not None and getattr(details, "text_tokens", None) is not None:
|
||||
details.text_tokens = recovered_output_tokens
|
||||
return usage
|
||||
|
||||
@staticmethod
|
||||
def _clear_placeholder_cost(response: ModelResponse, usage: Usage) -> None:
|
||||
usage.cost = None
|
||||
response._hidden_params.pop("response_cost", None) # pyright: ignore[reportPrivateUsage] # no public accessor
|
||||
|
||||
@staticmethod
|
||||
def _create_anthropic_response_logging_payload(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ This allows the same policy to be attached to multiple scopes.
|
|||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -141,8 +142,11 @@ class AttachmentRegistry:
|
|||
),
|
||||
key=_attachment_specificity,
|
||||
)
|
||||
broadest_attachment_by_policy: Final = MappingProxyType(
|
||||
{attachment.policy: attachment for attachment in reversed(matching_attachments)}
|
||||
)
|
||||
unique_attachments: Final = tuple(
|
||||
next(attachment for attachment in matching_attachments if attachment.policy == policy_name)
|
||||
broadest_attachment_by_policy[policy_name]
|
||||
for policy_name in dict.fromkeys(attachment.policy for attachment in matching_attachments)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ sys.path.insert(0, os.path.abspath("./"))
|
|||
|
||||
from typing import Final
|
||||
|
||||
from litellm_proxy_extras.prisma_toolchain import resolve_prisma_argv
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy.proxy_cli import run_server
|
||||
from litellm.secret_managers.main import str_to_bool
|
||||
|
|
@ -29,7 +31,7 @@ def main() -> int:
|
|||
run_server(run_server_args, standalone_mode=False)
|
||||
|
||||
verbose_proxy_logger.info("Running 'prisma generate'...")
|
||||
result: Final = subprocess.run(("prisma", "generate"), capture_output=True, text=True)
|
||||
result: Final = subprocess.run(resolve_prisma_argv(("prisma", "generate")), capture_output=True, text=True)
|
||||
verbose_proxy_logger.info("'prisma generate' stdout: %s", result.stdout)
|
||||
|
||||
if result.returncode != 0:
|
||||
|
|
|
|||
|
|
@ -1267,73 +1267,69 @@ def run_server(
|
|||
flush=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
try:
|
||||
from litellm.secret_managers.main import get_secret
|
||||
from litellm.secret_managers.main import get_secret
|
||||
|
||||
connection_url_params: Final = _build_db_connection_url_params(
|
||||
connection_limit=db_connection_pool_limit,
|
||||
pool_timeout=db_connection_timeout,
|
||||
connect_timeout=db_connect_timeout,
|
||||
socket_timeout=db_socket_timeout,
|
||||
disable_prepared_statements=db_disable_prepared_statements,
|
||||
extra_params=db_extra_connection_params,
|
||||
connection_url_params: Final = _build_db_connection_url_params(
|
||||
connection_limit=db_connection_pool_limit,
|
||||
pool_timeout=db_connection_timeout,
|
||||
connect_timeout=db_connect_timeout,
|
||||
socket_timeout=db_socket_timeout,
|
||||
disable_prepared_statements=db_disable_prepared_statements,
|
||||
extra_params=db_extra_connection_params,
|
||||
)
|
||||
lifetime_params: Final = idle_lifetime_params(general_settings.get("database_max_idle_connection_lifetime"))
|
||||
if os.getenv("DATABASE_URL", None) is not None:
|
||||
database_url = get_secret("DATABASE_URL", default_value=None)
|
||||
resolved_url: Final[str | None] = str(database_url) if database_url else None
|
||||
pg_options: Final[str] = _pg_options_with_timeouts(
|
||||
_url_query_value(resolved_url, "options"),
|
||||
db_statement_timeout,
|
||||
db_lock_timeout,
|
||||
)
|
||||
lifetime_params: Final = idle_lifetime_params(
|
||||
general_settings.get("database_max_idle_connection_lifetime")
|
||||
writer_url: Final = (
|
||||
_with_query_value(resolved_url, "options", pg_options)
|
||||
if resolved_url and pg_options
|
||||
else resolved_url
|
||||
)
|
||||
if os.getenv("DATABASE_URL", None) is not None:
|
||||
database_url = get_secret("DATABASE_URL", default_value=None)
|
||||
resolved_url: Final[str | None] = str(database_url) if database_url else None
|
||||
pg_options: Final[str] = _pg_options_with_timeouts(
|
||||
_url_query_value(resolved_url, "options"),
|
||||
db_statement_timeout,
|
||||
db_lock_timeout,
|
||||
)
|
||||
writer_url: Final = (
|
||||
_with_query_value(resolved_url, "options", pg_options)
|
||||
if resolved_url and pg_options
|
||||
else resolved_url
|
||||
)
|
||||
modified_url = append_query_params(
|
||||
writer_url,
|
||||
connection_url_params,
|
||||
)
|
||||
os.environ["DATABASE_URL"] = translate_libpq_ssl_params(
|
||||
add_missing_query_params(modified_url, lifetime_params)
|
||||
)
|
||||
if os.getenv("DIRECT_URL", None) is not None:
|
||||
database_url = os.getenv("DIRECT_URL")
|
||||
modified_url = append_query_params(database_url, connection_url_params)
|
||||
os.environ["DIRECT_URL"] = translate_libpq_ssl_params(
|
||||
add_missing_query_params(modified_url, lifetime_params)
|
||||
)
|
||||
# The reader pool is a real pool against the same configured cap, so it
|
||||
# gets the allowlisted pool params. Schema-affecting ones, including any
|
||||
# the operator smuggled in through database_extra_connection_params, stay
|
||||
# on the writer. Anything pinned on the replica URL wins, unlike the
|
||||
# writer where the config is applied on top.
|
||||
read_replica_url: Final[str | None] = os.getenv("DATABASE_URL_READ_REPLICA")
|
||||
if read_replica_url:
|
||||
reader_options: Final[str] = _pg_options_with_timeouts(
|
||||
_url_query_value(read_replica_url, "options"),
|
||||
db_statement_timeout,
|
||||
db_lock_timeout,
|
||||
)
|
||||
os.environ["DATABASE_URL_READ_REPLICA"] = translate_libpq_ssl_params(
|
||||
modified_url = append_query_params(
|
||||
writer_url,
|
||||
connection_url_params,
|
||||
)
|
||||
os.environ["DATABASE_URL"] = translate_libpq_ssl_params(
|
||||
add_missing_query_params(modified_url, lifetime_params)
|
||||
)
|
||||
if os.getenv("DIRECT_URL", None) is not None:
|
||||
database_url = os.getenv("DIRECT_URL")
|
||||
modified_url = append_query_params(database_url, connection_url_params)
|
||||
os.environ["DIRECT_URL"] = translate_libpq_ssl_params(
|
||||
add_missing_query_params(modified_url, lifetime_params)
|
||||
)
|
||||
# The reader pool is a real pool against the same configured cap, so it
|
||||
# gets the allowlisted pool params. Schema-affecting ones, including any
|
||||
# the operator smuggled in through database_extra_connection_params, stay
|
||||
# on the writer. Anything pinned on the replica URL wins, unlike the
|
||||
# writer where the config is applied on top.
|
||||
read_replica_url: Final[str | None] = os.getenv("DATABASE_URL_READ_REPLICA")
|
||||
if read_replica_url:
|
||||
reader_options: Final[str] = _pg_options_with_timeouts(
|
||||
_url_query_value(read_replica_url, "options"),
|
||||
db_statement_timeout,
|
||||
db_lock_timeout,
|
||||
)
|
||||
os.environ["DATABASE_URL_READ_REPLICA"] = translate_libpq_ssl_params(
|
||||
add_missing_query_params(
|
||||
add_missing_query_params(
|
||||
add_missing_query_params(
|
||||
_with_query_value(read_replica_url, "options", reader_options)
|
||||
if reader_options
|
||||
else read_replica_url,
|
||||
reader_shareable_params(connection_url_params),
|
||||
),
|
||||
lifetime_params,
|
||||
)
|
||||
_with_query_value(read_replica_url, "options", reader_options)
|
||||
if reader_options
|
||||
else read_replica_url,
|
||||
reader_shareable_params(connection_url_params),
|
||||
),
|
||||
lifetime_params,
|
||||
)
|
||||
subprocess.run(["prisma"], capture_output=True)
|
||||
is_prisma_runnable = True
|
||||
except FileNotFoundError:
|
||||
is_prisma_runnable = False
|
||||
)
|
||||
from litellm_proxy_extras.prisma_toolchain import prisma_cli_available
|
||||
|
||||
is_prisma_runnable: Final = prisma_cli_available()
|
||||
|
||||
if is_prisma_runnable:
|
||||
from litellm.proxy.db.check_migration import check_prisma_schema_diff
|
||||
|
|
@ -1382,7 +1378,8 @@ def run_server(
|
|||
)
|
||||
else:
|
||||
print(
|
||||
f"Unable to connect to DB. DATABASE_URL found in environment, but prisma package not found." # noqa: F541
|
||||
"Unable to connect to DB. DATABASE_URL found in environment, but the prisma CLI is neither on "
|
||||
"PATH nor importable as a package."
|
||||
)
|
||||
pgbouncer_settings: Final = PgBouncerSettings()
|
||||
upstream_database_url: Final = os.getenv("DATABASE_URL")
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
|
|||
AuthCacheInvalidationSubscriber,
|
||||
)
|
||||
from litellm.proxy.common_utils.callback_utils import initialize_callbacks_on_proxy
|
||||
from litellm.proxy.common_utils.config_includes import resolve_include_file_path, resolve_includes
|
||||
from litellm.proxy.common_utils.config_sync_pubsub import ConfigSyncSubscriber
|
||||
from litellm.proxy.common_utils.debug_utils import init_verbose_loggers
|
||||
from litellm.proxy.common_utils.debug_utils import router as debugging_endpoints_router
|
||||
|
|
@ -371,10 +372,7 @@ from litellm.proxy.common_utils.http_parsing_utils import (
|
|||
check_file_size_under_limit,
|
||||
get_form_data,
|
||||
)
|
||||
from litellm.proxy.common_utils.load_config_utils import (
|
||||
get_config_file_contents_from_gcs,
|
||||
get_file_contents_from_s3,
|
||||
)
|
||||
from litellm.proxy.common_utils.load_config_utils import get_config_from_bucket
|
||||
from litellm.proxy.common_utils.model_deprecation import collect_model_deprecations
|
||||
from litellm.proxy.common_utils.model_listing_utils import (
|
||||
ClaudeCodeRoutingNames,
|
||||
|
|
@ -448,7 +446,10 @@ from litellm.proxy.db.proxy_worker_heartbeat import (
|
|||
ProxyWorkerHeartbeat,
|
||||
)
|
||||
from litellm.proxy.db.spend_counter_reseed import END_USER_COUNTER_PREFIX, SpendCounterReseed
|
||||
from litellm.proxy.discovery_endpoints import ui_discovery_endpoints_router
|
||||
from litellm.proxy.discovery_endpoints import (
|
||||
agent_skills_discovery_router,
|
||||
ui_discovery_endpoints_router,
|
||||
)
|
||||
from litellm.proxy.fine_tuning_endpoints.endpoints import router as fine_tuning_router
|
||||
from litellm.proxy.fine_tuning_endpoints.endpoints import set_fine_tuning_config
|
||||
from litellm.proxy.google_endpoints.endpoints import router as google_router
|
||||
|
|
@ -4803,12 +4804,12 @@ class ProxyConfig:
|
|||
if config is None:
|
||||
raise Exception("Config cannot be None or Empty.")
|
||||
# Process includes
|
||||
config = self._process_includes(config=config, base_dir=os.path.dirname(os.path.abspath(file_path or "")))
|
||||
config = await self._process_includes(config=config, config_file_path=os.path.abspath(file_path or ""))
|
||||
|
||||
# verbose_proxy_logger.debug(f"loaded config={json.dumps(config, indent=4)}")
|
||||
return config
|
||||
|
||||
def _process_includes(self, config: dict, base_dir: str) -> dict:
|
||||
async def _process_includes(self, config: dict, config_file_path: str) -> dict:
|
||||
"""
|
||||
Process includes by appending their contents to the main config
|
||||
|
||||
|
|
@ -4823,29 +4824,21 @@ class ProxyConfig:
|
|||
callbacks: ["prometheus"]
|
||||
```
|
||||
"""
|
||||
if "include" not in config:
|
||||
return config
|
||||
|
||||
if not isinstance(config["include"], list):
|
||||
raise ValueError("'include' must be a list of file paths")
|
||||
included_config_adapter: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
# Load and append all included files
|
||||
for include_file in config["include"]:
|
||||
file_path = os.path.join(base_dir, include_file)
|
||||
def resolve(include_file: str, declared_in: str) -> str:
|
||||
return resolve_include_file_path(include_file, declared_in, config_file_path)
|
||||
|
||||
async def read_included(file_path: str) -> Mapping[str, object]:
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"Included file not found: {file_path}")
|
||||
try:
|
||||
return included_config_adapter.validate_python(self._load_yaml_file(file_path))
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"Included config file is not a YAML mapping: {file_path}") from e
|
||||
|
||||
included_config = self._load_yaml_file(file_path)
|
||||
# Simply update/extend the main config with included config
|
||||
for key, value in included_config.items():
|
||||
if isinstance(value, list) and key in config:
|
||||
config[key].extend(value)
|
||||
else:
|
||||
config[key] = value
|
||||
|
||||
# Remove the include directive
|
||||
del config["include"]
|
||||
return config
|
||||
return await resolve_includes(config=config, location=config_file_path, resolve=resolve, read=read_included)
|
||||
|
||||
async def save_config(self, new_config: dict, include_env_vars: bool = False):
|
||||
global prisma_client, general_settings, user_config_file_path, store_model_in_db
|
||||
|
|
@ -5203,15 +5196,19 @@ class ProxyConfig:
|
|||
global prisma_client, store_model_in_db
|
||||
# Load existing config
|
||||
|
||||
if os.environ.get("LITELLM_CONFIG_BUCKET_NAME") is not None:
|
||||
bucket_name: Final = os.environ.get("LITELLM_CONFIG_BUCKET_NAME")
|
||||
bucket_name: Final = os.environ.get("LITELLM_CONFIG_BUCKET_NAME")
|
||||
if bucket_name is not None:
|
||||
object_key: Final = os.environ.get("LITELLM_CONFIG_BUCKET_OBJECT_KEY")
|
||||
bucket_type: Final = os.environ.get("LITELLM_CONFIG_BUCKET_TYPE")
|
||||
verbose_proxy_logger.debug("bucket_name: %s, object_key: %s", bucket_name, object_key)
|
||||
if bucket_type == "gcs":
|
||||
config = await get_config_file_contents_from_gcs(bucket_name=bucket_name, object_key=object_key)
|
||||
else:
|
||||
config = get_file_contents_from_s3(bucket_name=bucket_name, object_key=object_key)
|
||||
if object_key is None:
|
||||
raise Exception("LITELLM_CONFIG_BUCKET_OBJECT_KEY must be set to load the config from a bucket.")
|
||||
|
||||
config = await get_config_from_bucket(
|
||||
bucket_type=bucket_type,
|
||||
bucket_name=bucket_name,
|
||||
object_key=object_key,
|
||||
)
|
||||
|
||||
if config is None:
|
||||
raise Exception("Unable to load config from given source.")
|
||||
|
|
@ -18798,6 +18795,7 @@ app.include_router(user_agent_analytics_router)
|
|||
app.include_router(gateway_request_router)
|
||||
app.include_router(enterprise_router)
|
||||
app.include_router(ui_discovery_endpoints_router)
|
||||
app.include_router(agent_skills_discovery_router)
|
||||
# Eager: /models/{name}:method overlaps with the OpenAI /models endpoint.
|
||||
app.include_router(google_router)
|
||||
|
||||
|
|
|
|||
21
litellm/types/llms/meta.py
Normal file
21
litellm/types/llms/meta.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from typing import Literal, TypeAlias
|
||||
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
MuseMode: TypeAlias = Literal["PUSH_TO_TALK", "ENDPOINTING"]
|
||||
MuseAudioEncoding: TypeAlias = Literal["PCM_16KHZ", "PCM_24KHZ"]
|
||||
MuseSampleRate: TypeAlias = Literal[16000, 24000]
|
||||
|
||||
|
||||
class MuseAuthorization(TypedDict):
|
||||
accessToken: ReadOnly[str]
|
||||
|
||||
|
||||
class MuseHandshake(TypedDict):
|
||||
authorization: ReadOnly[MuseAuthorization]
|
||||
audioEncoding: ReadOnly[MuseAudioEncoding]
|
||||
model: ReadOnly[str]
|
||||
mode: ReadOnly[MuseMode]
|
||||
partialMode: ReadOnly[Literal["CUMULATIVE"]]
|
||||
emitAudioProgress: ReadOnly[bool]
|
||||
languageBias: NotRequired[ReadOnly[tuple[str, ...]]]
|
||||
|
|
@ -2197,6 +2197,53 @@ class OpenAIRealtimeInputAudioBufferSpeechEvent(TypedDict):
|
|||
item_id: ReadOnly[str]
|
||||
|
||||
|
||||
class OpenAIRealtimeErrorDetail(TypedDict):
|
||||
type: ReadOnly[str]
|
||||
message: ReadOnly[str]
|
||||
|
||||
|
||||
class OpenAIRealtimeErrorEvent(TypedDict):
|
||||
type: ReadOnly[Literal["error"]]
|
||||
error: ReadOnly[OpenAIRealtimeErrorDetail]
|
||||
|
||||
|
||||
class OpenAIRealtimeTranscriptionAudioFormat(TypedDict):
|
||||
type: ReadOnly[Literal["audio/pcm"]]
|
||||
rate: ReadOnly[int]
|
||||
|
||||
|
||||
class OpenAIRealtimeTranscriptionSettings(TypedDict):
|
||||
model: ReadOnly[str]
|
||||
language: NotRequired[ReadOnly[str]]
|
||||
|
||||
|
||||
class OpenAIRealtimeServerVadTurnDetection(TypedDict):
|
||||
type: ReadOnly[Literal["server_vad"]]
|
||||
|
||||
|
||||
class OpenAIRealtimeTranscriptionAudioInput(TypedDict):
|
||||
format: ReadOnly[OpenAIRealtimeTranscriptionAudioFormat]
|
||||
transcription: ReadOnly[OpenAIRealtimeTranscriptionSettings]
|
||||
turn_detection: ReadOnly[OpenAIRealtimeServerVadTurnDetection | None]
|
||||
|
||||
|
||||
class OpenAIRealtimeTranscriptionAudio(TypedDict):
|
||||
input: ReadOnly[OpenAIRealtimeTranscriptionAudioInput]
|
||||
|
||||
|
||||
class OpenAIRealtimeTranscriptionSession(TypedDict):
|
||||
id: ReadOnly[str]
|
||||
object: ReadOnly[Literal["realtime.transcription_session"]]
|
||||
type: ReadOnly[Literal["transcription"]]
|
||||
audio: ReadOnly[OpenAIRealtimeTranscriptionAudio]
|
||||
|
||||
|
||||
class OpenAIRealtimeTranscriptionSessionCreated(TypedDict):
|
||||
type: ReadOnly[Literal["session.created"]]
|
||||
event_id: ReadOnly[str]
|
||||
session: ReadOnly[OpenAIRealtimeTranscriptionSession]
|
||||
|
||||
|
||||
class OpenAIRealtimeInputAudioTranscriptionDelta(TypedDict):
|
||||
type: ReadOnly[Literal["conversation.item.input_audio_transcription.delta"]]
|
||||
event_id: ReadOnly[str]
|
||||
|
|
@ -2211,6 +2258,7 @@ class OpenAIRealtimeInputAudioTranscriptionCompleted(TypedDict):
|
|||
item_id: ReadOnly[str]
|
||||
content_index: ReadOnly[int]
|
||||
transcript: ReadOnly[str]
|
||||
usage: NotRequired[ReadOnly[Mapping[str, object]]]
|
||||
|
||||
|
||||
class OpenAIRealtimeCachedTokensDetails(TypedDict, total=False):
|
||||
|
|
@ -2274,6 +2322,8 @@ OpenAIRealtimeEvents = (
|
|||
| OpenAIRealtimeInputAudioBufferSpeechEvent
|
||||
| OpenAIRealtimeInputAudioTranscriptionDelta
|
||||
| OpenAIRealtimeInputAudioTranscriptionCompleted
|
||||
| OpenAIRealtimeTranscriptionSessionCreated
|
||||
| OpenAIRealtimeErrorEvent
|
||||
)
|
||||
|
||||
OpenAIRealtimeStreamList = list[OpenAIRealtimeEvents]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
"""Agent Skills discovery index, version 0.2.0.
|
||||
|
||||
Schema: https://schemas.agentskills.io/discovery/0.2.0/schema.json
|
||||
"""
|
||||
|
||||
from typing import Final, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
AGENT_SKILLS_DISCOVERY_SCHEMA_URL: Final = "https://schemas.agentskills.io/discovery/0.2.0/schema.json"
|
||||
MAX_SKILL_NAME_LENGTH: Final = 64
|
||||
MAX_SKILL_DESCRIPTION_LENGTH: Final = 1024
|
||||
|
||||
|
||||
class AgentSkillsIndexEntry(BaseModel):
|
||||
name: str
|
||||
type: Literal["archive"]
|
||||
description: str
|
||||
url: str
|
||||
digest: str
|
||||
|
||||
|
||||
class AgentSkillsIndex(BaseModel):
|
||||
discovery_schema: str = Field(default=AGENT_SKILLS_DISCOVERY_SCHEMA_URL, alias="$schema")
|
||||
skills: tuple[AgentSkillsIndexEntry, ...]
|
||||
|
|
@ -43,6 +43,7 @@ class KeyMetadata(BaseModel):
|
|||
|
||||
key_alias: str | None = None
|
||||
team_id: str | None = None
|
||||
user_id: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -169,9 +169,19 @@ class RealtimeInputAudioTranscriptionUsageInputTokenDetails(TypedDict):
|
|||
audio_tokens: ReadOnly[int]
|
||||
|
||||
|
||||
class RealtimeInputAudioTranscriptionUsage(TypedDict):
|
||||
class RealtimeInputAudioTranscriptionTokenUsage(TypedDict):
|
||||
type: ReadOnly[Literal["tokens"]]
|
||||
input_tokens: ReadOnly[int]
|
||||
output_tokens: ReadOnly[int]
|
||||
total_tokens: ReadOnly[int]
|
||||
input_token_details: ReadOnly[RealtimeInputAudioTranscriptionUsageInputTokenDetails]
|
||||
|
||||
|
||||
class RealtimeInputAudioTranscriptionDurationUsage(TypedDict):
|
||||
type: ReadOnly[Literal["duration"]]
|
||||
seconds: ReadOnly[float]
|
||||
|
||||
|
||||
RealtimeInputAudioTranscriptionUsage = (
|
||||
RealtimeInputAudioTranscriptionTokenUsage | RealtimeInputAudioTranscriptionDurationUsage
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9286,6 +9286,10 @@ class ProviderConfigManager:
|
|||
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
|
||||
|
||||
return GeminiRealtimeConfig()
|
||||
if LlmProviders.META == provider:
|
||||
from litellm.llms.meta.realtime.transformation import MetaRealtimeConfig
|
||||
|
||||
return MetaRealtimeConfig()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -4497,7 +4497,7 @@
|
|||
},
|
||||
"azure/eu/o1-2024-12-17": {
|
||||
"cache_read_input_token_cost": 8.25e-06,
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.65e-05,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -4543,7 +4543,7 @@
|
|||
},
|
||||
"azure/eu/o3-mini-2025-01-31": {
|
||||
"cache_read_input_token_cost": 6.05e-07,
|
||||
"deprecation_date": "2026-10-01",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.21e-06,
|
||||
"input_cost_per_token_batches": 6.05e-07,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8730,7 +8730,7 @@
|
|||
"supports_function_calling": true
|
||||
},
|
||||
"azure/o1": {
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 7.5e-06,
|
||||
"input_cost_per_token": 1.5e-05,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8748,7 +8748,7 @@
|
|||
},
|
||||
"azure/o1-2024-12-17": {
|
||||
"cache_read_input_token_cost": 7.5e-06,
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.5e-05,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -8825,7 +8825,7 @@
|
|||
"supports_vision": false
|
||||
},
|
||||
"azure/o3": {
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8855,7 +8855,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"azure/o3-2025-04-16": {
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8886,7 +8886,7 @@
|
|||
},
|
||||
"azure/o3-deep-research": {
|
||||
"cache_read_input_token_cost": 2.5e-06,
|
||||
"deprecation_date": "2026-12-26",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1e-05,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -8923,7 +8923,7 @@
|
|||
"supports_web_search": true
|
||||
},
|
||||
"azure/o3-mini": {
|
||||
"deprecation_date": "2026-10-01",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"input_cost_per_token": 1.1e-06,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8940,7 +8940,7 @@
|
|||
},
|
||||
"azure/o3-mini-2025-01-31": {
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"deprecation_date": "2026-10-01",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.1e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -8954,7 +8954,7 @@
|
|||
"supports_vision": false
|
||||
},
|
||||
"azure/o3-pro": {
|
||||
"deprecation_date": "2026-12-17",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 2e-05,
|
||||
"input_cost_per_token_batches": 1e-05,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -8985,7 +8985,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"azure/o3-pro-2025-06-10": {
|
||||
"deprecation_date": "2026-12-17",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 2e-05,
|
||||
"input_cost_per_token_batches": 1e-05,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -9016,7 +9016,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"azure/o4-mini": {
|
||||
"deprecation_date": "2026-10-16",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 2.75e-07,
|
||||
"input_cost_per_token": 1.1e-06,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -9047,7 +9047,7 @@
|
|||
},
|
||||
"azure/o4-mini-2025-04-16": {
|
||||
"cache_read_input_token_cost": 2.75e-07,
|
||||
"deprecation_date": "2026-10-16",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.1e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -9600,7 +9600,7 @@
|
|||
},
|
||||
"azure/us/o1-2024-12-17": {
|
||||
"cache_read_input_token_cost": 8.25e-06,
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.65e-05,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -9645,7 +9645,7 @@
|
|||
"supports_vision": false
|
||||
},
|
||||
"azure/us/o3-2025-04-16": {
|
||||
"deprecation_date": "2026-10-21",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"input_cost_per_token": 2.2e-06,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -9676,7 +9676,7 @@
|
|||
},
|
||||
"azure/us/o3-mini-2025-01-31": {
|
||||
"cache_read_input_token_cost": 6.05e-07,
|
||||
"deprecation_date": "2026-10-01",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.21e-06,
|
||||
"input_cost_per_token_batches": 6.05e-07,
|
||||
"litellm_provider": "azure",
|
||||
|
|
@ -9693,7 +9693,7 @@
|
|||
},
|
||||
"azure/us/o4-mini-2025-04-16": {
|
||||
"cache_read_input_token_cost": 3.1e-07,
|
||||
"deprecation_date": "2026-10-16",
|
||||
"deprecation_date": "2026-11-19",
|
||||
"input_cost_per_token": 1.21e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -14615,7 +14615,7 @@
|
|||
},
|
||||
"computer-use-preview": {
|
||||
"input_cost_per_token": 3e-06,
|
||||
"litellm_provider": "azure",
|
||||
"litellm_provider": "openai",
|
||||
"max_input_tokens": 8192,
|
||||
"max_output_tokens": 1024,
|
||||
"max_tokens": 1024,
|
||||
|
|
@ -14633,12 +14633,14 @@
|
|||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_response_schema": false,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"source": "https://platform.openai.com/docs/models/computer-use-preview"
|
||||
},
|
||||
"dall-e-2": {
|
||||
"deprecation_date": "2026-05-12",
|
||||
|
|
@ -34720,6 +34722,22 @@
|
|||
"supports_web_search": true,
|
||||
"supports_xhigh_reasoning_effort": true
|
||||
},
|
||||
"meta/muse-voice-transcribe-1.0": {
|
||||
"input_cost_per_second": 0.00005,
|
||||
"litellm_provider": "meta",
|
||||
"mode": "audio_transcription",
|
||||
"source": "https://dev.meta.ai/docs/speech-to-text",
|
||||
"supported_endpoints": [
|
||||
"/v1/realtime"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_input": true
|
||||
},
|
||||
"meta_llama/Llama-3.3-70B-Instruct": {
|
||||
"litellm_provider": "meta_llama",
|
||||
"max_input_tokens": 128000,
|
||||
|
|
@ -39022,7 +39040,9 @@
|
|||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"prompt_cache_min_tokens": 4096
|
||||
"prompt_cache_min_tokens": 4096,
|
||||
"supports_response_schema": true,
|
||||
"source": "https://openrouter.ai/api/v1/models"
|
||||
},
|
||||
"openrouter/anthropic/claude-sonnet-4.5": {
|
||||
"input_cost_per_image": 0.0048,
|
||||
|
|
@ -39170,18 +39190,20 @@
|
|||
},
|
||||
"openrouter/deepseek/deepseek-v3.2": {
|
||||
"input_cost_per_token": 2.69e-07,
|
||||
"input_cost_per_token_cache_hit": 2.8e-08,
|
||||
"input_cost_per_token_cache_hit": 1.345e-07,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 163840,
|
||||
"max_output_tokens": 163840,
|
||||
"max_tokens": 163840,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-07,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
"supports_tool_choice": true,
|
||||
"supports_response_schema": true,
|
||||
"source": "https://openrouter.ai/api/v1/models"
|
||||
},
|
||||
"openrouter/deepseek/deepseek-v3.2-exp": {
|
||||
"input_cost_per_token": 2.7e-07,
|
||||
|
|
@ -43491,6 +43513,7 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"together_ai/openai/gpt-oss-20b": {
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 5e-08,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -43783,6 +43806,7 @@
|
|||
"source": "https://docs.together.ai/docs/serverless-models"
|
||||
},
|
||||
"together_ai/google/gemma-4-31B-it": {
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 3.9e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 262144,
|
||||
|
|
@ -43797,6 +43821,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"together_ai/intfloat/multilingual-e5-large-instruct": {
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 2e-08,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 514,
|
||||
|
|
@ -43909,6 +43934,7 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"together_ai/thinkingmachines/Inkling-Small": {
|
||||
"deprecation_date": "2026-09-14",
|
||||
"cache_read_input_token_cost": 1e-07,
|
||||
"input_cost_per_token": 5e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -59101,9 +59127,9 @@
|
|||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
"supports_reasoning": true,
|
||||
"input_cost_per_token": 0.00000131,
|
||||
"output_cost_per_token": 0.00000396,
|
||||
"cache_read_input_token_cost": 0.000000044,
|
||||
"input_cost_per_token": 1.31e-06,
|
||||
"output_cost_per_token": 3.96e-06,
|
||||
"cache_read_input_token_cost": 4.4e-08,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
|
|
@ -59111,9 +59137,9 @@
|
|||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
"supports_reasoning": true,
|
||||
"input_cost_per_token": 0.0000001,
|
||||
"output_cost_per_token": 0.00000015,
|
||||
"cache_read_input_token_cost": 0.00000005,
|
||||
"input_cost_per_token": 1e-07,
|
||||
"output_cost_per_token": 1.5e-07,
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
|
|
@ -60835,6 +60861,7 @@
|
|||
"source": "https://docs.together.ai/docs/serverless-models"
|
||||
},
|
||||
"together_ai/moonshotai/Kimi-K2.6": {
|
||||
"deprecation_date": "2026-08-19",
|
||||
"input_cost_per_token": 1.2e-06,
|
||||
"output_cost_per_token": 4.5e-06,
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
|
|
@ -60861,6 +60888,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/zai-org/GLM-5": {
|
||||
"deprecation_date": "2026-06-22",
|
||||
"input_cost_per_token": 1e-06,
|
||||
"output_cost_per_token": 3.2e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -60869,6 +60897,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/zai-org/GLM-5.1": {
|
||||
"deprecation_date": "2026-07-10",
|
||||
"input_cost_per_token": 1.4e-06,
|
||||
"output_cost_per_token": 4.4e-06,
|
||||
"cache_read_input_token_cost": 2.6e-07,
|
||||
|
|
@ -60886,6 +60915,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/Qwen/Qwen3-Coder-Next-FP8": {
|
||||
"deprecation_date": "2026-05-14",
|
||||
"input_cost_per_token": 5e-07,
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -60894,6 +60924,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/Qwen/Qwen3-VL-32B-Instruct": {
|
||||
"deprecation_date": "2026-02-25",
|
||||
"input_cost_per_token": 5e-07,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -60902,6 +60933,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/Qwen/Qwen3-VL-8B-Instruct": {
|
||||
"deprecation_date": "2026-04-16",
|
||||
"input_cost_per_token": 1.8e-07,
|
||||
"output_cost_per_token": 6.8e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -60934,6 +60966,7 @@
|
|||
"source": "https://api.together.xyz/v1/models"
|
||||
},
|
||||
"together_ai/Qwen/QwQ-32B": {
|
||||
"deprecation_date": "2025-11-13",
|
||||
"input_cost_per_token": 1.2e-06,
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ proxy = [
|
|||
"azure-identity>=1.25.2,<2.0",
|
||||
"azure-storage-blob>=12.28.0,<13.0",
|
||||
"mcp>=1.28.1,<2.0",
|
||||
"litellm-proxy-extras==0.4.96",
|
||||
"litellm-enterprise==0.1.66",
|
||||
"litellm-proxy-extras==0.4.97",
|
||||
"litellm-enterprise==0.1.67",
|
||||
"RestrictedPython>=8.5,<9.0",
|
||||
"rich>=13.9.4,<14.0",
|
||||
"InquirerPy>=0.3.4,<1.0",
|
||||
|
|
|
|||
|
|
@ -1292,6 +1292,21 @@ async def test_update_project_leaves_metadata_untouched_when_no_limit_is_sent(mo
|
|||
assert "metadata" not in _written_project_data(mock_prisma)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_clears_only_the_explicit_budget_cap(monkeypatch):
|
||||
mock_prisma = _project_update_mocks(monkeypatch, {})
|
||||
mock_prisma.db.litellm_projecttable.find_unique.return_value.budget_id = "budget-clear-test"
|
||||
mock_prisma.db.litellm_budgettable.update = mock.AsyncMock()
|
||||
|
||||
await _run_project_update("project-clear-test", max_budget=None)
|
||||
|
||||
mock_prisma.db.litellm_budgettable.update.assert_awaited_once_with(
|
||||
where={"budget_id": "budget-clear-test"},
|
||||
data={"max_budget": None, "updated_by": "1234"},
|
||||
)
|
||||
assert "max_budget" not in _written_project_data(mock_prisma)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["all-proxy-models", "*", "azure/*"])
|
||||
def test_enforce_project_model_quota_rejects_entries_that_expand_at_request_time(entry):
|
||||
"""A quota keyed on a wildcard entry is never applied by the limiter, so it fails loudly."""
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ from litellm_proxy_extras.prisma_toolchain import (
|
|||
node_binary_path,
|
||||
prisma_bootstrap_timeout,
|
||||
prisma_command_timeout,
|
||||
prisma_cli_available,
|
||||
prisma_migrate_deploy_timeout,
|
||||
resolve_prisma_argv,
|
||||
run_prisma,
|
||||
)
|
||||
from litellm_proxy_extras.utils import ProxyExtrasDBManager
|
||||
|
||||
|
|
@ -401,3 +404,95 @@ def test_every_prisma_command_timeout_is_overridable(module: str) -> None:
|
|||
f"{module} still hardcodes a Prisma timeout at lines {literals}; "
|
||||
"route it through prisma_command_timeout() so it can be raised without a release"
|
||||
)
|
||||
|
||||
|
||||
FAKE_PRISMA_MODULE_MAIN = """import json
|
||||
import sys
|
||||
|
||||
print(json.dumps({"module_argv": sys.argv[1:]}))
|
||||
"""
|
||||
|
||||
|
||||
def _write_fake_prisma_module(tmp_path: Path) -> Path:
|
||||
package_dir = tmp_path / "fakemodule" / "prisma"
|
||||
package_dir.mkdir(parents=True)
|
||||
(package_dir / "__init__.py").write_text("")
|
||||
(package_dir / "__main__.py").write_text(FAKE_PRISMA_MODULE_MAIN)
|
||||
return package_dir.parent
|
||||
|
||||
|
||||
def _empty_bin(tmp_path: Path) -> Path:
|
||||
bin_dir = tmp_path / "emptybin"
|
||||
bin_dir.mkdir()
|
||||
return bin_dir
|
||||
|
||||
|
||||
def test_run_prisma_uses_the_module_when_the_console_script_is_not_on_path(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
empty_bin = _empty_bin(tmp_path)
|
||||
module_root = _write_fake_prisma_module(tmp_path)
|
||||
monkeypatch.setenv("PATH", str(empty_bin))
|
||||
|
||||
result = run_prisma(
|
||||
["prisma", "migrate", "deploy"],
|
||||
timeout=60,
|
||||
env={"PATH": str(empty_bin), "PYTHONPATH": str(module_root)},
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"module_argv": ["migrate", "deploy"]}
|
||||
|
||||
|
||||
def test_run_prisma_prefers_the_console_script_on_path(
|
||||
toolchain_env: tuple[Path, Path], tmp_path: Path
|
||||
) -> None:
|
||||
_, log_path = toolchain_env
|
||||
module_root = _write_fake_prisma_module(tmp_path)
|
||||
|
||||
result = run_prisma(
|
||||
["prisma", "--version"],
|
||||
timeout=60,
|
||||
env={**os.environ, "PYTHONPATH": str(module_root)},
|
||||
)
|
||||
|
||||
assert [call["args"] for call in _fake_prisma_calls(log_path)] == [["--version"]]
|
||||
assert "module_argv" not in result.stdout
|
||||
|
||||
|
||||
def test_resolve_prisma_argv_leaves_an_explicit_cli_path_alone(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("PATH", str(_empty_bin(tmp_path)))
|
||||
explicit = ("/app/.cache/prisma-python/prisma", "migrate", "deploy")
|
||||
|
||||
assert resolve_prisma_argv(explicit) == explicit
|
||||
|
||||
|
||||
def test_prisma_cli_is_unavailable_with_neither_script_nor_package(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("PATH", str(_empty_bin(tmp_path)))
|
||||
monkeypatch.delitem(sys.modules, "prisma", raising=False)
|
||||
monkeypatch.setattr(sys, "path", [])
|
||||
|
||||
assert prisma_cli_available() is False
|
||||
|
||||
|
||||
def test_prisma_cli_is_available_through_the_package_alone(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("PATH", str(_empty_bin(tmp_path)))
|
||||
monkeypatch.delitem(sys.modules, "prisma", raising=False)
|
||||
monkeypatch.setattr(sys, "path", [str(_write_fake_prisma_module(tmp_path))])
|
||||
|
||||
assert prisma_cli_available() is True
|
||||
|
||||
|
||||
def test_prisma_cli_is_available_through_the_console_script_alone(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("PATH", str(_write_fake_prisma(tmp_path)))
|
||||
monkeypatch.delitem(sys.modules, "prisma", raising=False)
|
||||
monkeypatch.setattr(sys, "path", [])
|
||||
|
||||
assert prisma_cli_available() is True
|
||||
|
|
|
|||
|
|
@ -1205,6 +1205,66 @@ async def test_a_probe_overtaken_by_a_later_outage_leaves_the_breaker_to_the_new
|
|||
assert breaker._state == breaker.CLOSED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_wait_timeout_is_a_timeout_failure_not_hard_connectivity():
|
||||
"""A saturated blocking pool must not open the breaker before the timeout minimum duration.
|
||||
|
||||
redis-py's async BlockingConnectionPool gives up waiting for a free connection by raising
|
||||
ConnectionError("No connection available.") chained from asyncio.TimeoutError. Redis itself
|
||||
is healthy in that case, so the failure has to be classed as a timeout and stay behind the
|
||||
duration gate instead of being counted as a hard connectivity failure.
|
||||
"""
|
||||
from fakeredis import FakeServer
|
||||
from fakeredis.aioredis import FakeConnection
|
||||
from redis.asyncio import BlockingConnectionPool, Redis
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
|
||||
from litellm.caching.redis_cache import RedisCircuitBreaker, _run_under_circuit_breaker
|
||||
|
||||
pool = BlockingConnectionPool(connection_class=FakeConnection, server=FakeServer(), max_connections=1, timeout=0.01)
|
||||
client = Redis(connection_pool=pool)
|
||||
breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=5.0)
|
||||
|
||||
busy_connection = await pool.get_connection()
|
||||
try:
|
||||
for _ in range(breaker.failure_threshold * 2):
|
||||
with pytest.raises(RedisConnectionError, match="No connection available"):
|
||||
await _run_under_circuit_breaker(breaker, "op", lambda: client.get("k"))
|
||||
finally:
|
||||
await pool.release(busy_connection)
|
||||
|
||||
assert breaker.is_open() is False, "a busy pool is a timeout gated on duration, not a dead Redis"
|
||||
assert await _run_under_circuit_breaker(breaker, "op", lambda: client.get("k")) is None
|
||||
await client.aclose()
|
||||
|
||||
|
||||
def test_timeout_classification_follows_the_explicit_cause_chain_only():
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
|
||||
from litellm.caching.redis_cache import _is_redis_timeout_failure
|
||||
|
||||
def raise_chained_from_timeout() -> None:
|
||||
try:
|
||||
raise asyncio.TimeoutError()
|
||||
except asyncio.TimeoutError as err:
|
||||
raise RedisConnectionError("No connection available.") from err
|
||||
|
||||
def raise_while_handling_timeout() -> None:
|
||||
try:
|
||||
raise asyncio.TimeoutError()
|
||||
except asyncio.TimeoutError:
|
||||
raise RedisConnectionError("refused")
|
||||
|
||||
with pytest.raises(RedisConnectionError) as chained:
|
||||
raise_chained_from_timeout()
|
||||
with pytest.raises(RedisConnectionError) as contextual:
|
||||
raise_while_handling_timeout()
|
||||
|
||||
assert _is_redis_timeout_failure(chained.value) is True
|
||||
assert _is_redis_timeout_failure(contextual.value) is False
|
||||
assert _is_redis_timeout_failure(RedisConnectionError("refused")) is False
|
||||
|
||||
|
||||
class _RoundTripCountingRedis:
|
||||
"""Fake redis.asyncio client: one round trip per awaited command or pipeline execute."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from importlib import import_module
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -5,18 +7,19 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import pytest
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _fake_redisvl_modules(semantic_cache_mock: MagicMock, custom_vectorizer_mock: MagicMock) -> Iterator[None]:
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setitem(sys.modules, "redisvl.extensions.llmcache", MagicMock(SemanticCache=semantic_cache_mock))
|
||||
mp.setitem(sys.modules, "redisvl.utils.vectorize", MagicMock(CustomTextVectorizer=custom_vectorizer_mock))
|
||||
yield
|
||||
|
||||
|
||||
# Tests for RedisSemanticCache
|
||||
def test_redis_semantic_cache_initialization(monkeypatch):
|
||||
# Mock the redisvl import
|
||||
semantic_cache_mock = MagicMock()
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(CustomTextVectorizer=MagicMock()),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, MagicMock()):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
# Set environment variables
|
||||
|
|
@ -44,15 +47,7 @@ def test_redis_semantic_cache_get_cache(monkeypatch):
|
|||
semantic_cache_mock = MagicMock()
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
# Set environment variables
|
||||
|
|
@ -110,15 +105,7 @@ def test_redis_semantic_cache_rejects_unscoped_cache_hit(monkeypatch):
|
|||
semantic_cache_mock = MagicMock()
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
monkeypatch.setenv("REDIS_HOST", "localhost")
|
||||
|
|
@ -162,15 +149,7 @@ def test_redis_semantic_cache_set_cache_stores_cache_key_filter(monkeypatch):
|
|||
semantic_cache_mock = MagicMock()
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
monkeypatch.setenv("REDIS_HOST", "localhost")
|
||||
|
|
@ -210,15 +189,7 @@ def test_redis_semantic_cache_uses_isolated_index_for_old_schema(monkeypatch):
|
|||
)
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
monkeypatch.setenv("REDIS_HOST", "localhost")
|
||||
|
|
@ -252,15 +223,7 @@ def test_redis_semantic_cache_overwrites_stale_isolated_index(monkeypatch):
|
|||
)
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
monkeypatch.setenv("REDIS_HOST", "localhost")
|
||||
|
|
@ -292,15 +255,7 @@ def test_redis_semantic_cache_reraises_unexpected_isolated_index_error(monkeypat
|
|||
)
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
monkeypatch.setenv("REDIS_HOST", "localhost")
|
||||
|
|
@ -369,15 +324,15 @@ def test_redis_semantic_cache_builds_filter_expression(monkeypatch):
|
|||
def __eq__(self, value):
|
||||
return (self.field_name, value)
|
||||
|
||||
with patch.dict("sys.modules", {"redisvl.query.filter": MagicMock(Tag=FakeTag)}):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
monkeypatch.setitem(sys.modules, "redisvl.query.filter", MagicMock(Tag=FakeTag))
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
redis_semantic_cache = RedisSemanticCache.__new__(RedisSemanticCache)
|
||||
redis_semantic_cache = RedisSemanticCache.__new__(RedisSemanticCache)
|
||||
|
||||
assert redis_semantic_cache._get_cache_key_filter_expression("test_key") == (
|
||||
RedisSemanticCache.CACHE_KEY_FIELD_NAME,
|
||||
"test_key",
|
||||
)
|
||||
assert redis_semantic_cache._get_cache_key_filter_expression("test_key") == (
|
||||
RedisSemanticCache.CACHE_KEY_FIELD_NAME,
|
||||
"test_key",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -386,15 +341,7 @@ async def test_redis_semantic_cache_async_get_cache(monkeypatch):
|
|||
semantic_cache_mock = MagicMock()
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
# Set environment variables
|
||||
|
|
@ -449,15 +396,7 @@ async def test_redis_semantic_cache_async_get_cache_rejects_unscoped_hit(monkeyp
|
|||
semantic_cache_mock = MagicMock()
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
monkeypatch.setenv("REDIS_HOST", "localhost")
|
||||
|
|
@ -499,15 +438,7 @@ async def test_redis_semantic_cache_async_set_cache_stores_cache_key_filter(
|
|||
semantic_cache_mock = MagicMock()
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
monkeypatch.setenv("REDIS_HOST", "localhost")
|
||||
|
|
@ -1255,15 +1186,7 @@ def test_redis_init_defers_redisvl_construction(monkeypatch):
|
|||
semantic_cache_mock = MagicMock()
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
monkeypatch.setenv("REDIS_HOST", "localhost")
|
||||
|
|
@ -1291,15 +1214,7 @@ def test_redis_failed_llmcache_build_is_not_memoized(monkeypatch):
|
|||
)
|
||||
custom_vectorizer_mock = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock),
|
||||
"redisvl.utils.vectorize": MagicMock(
|
||||
CustomTextVectorizer=custom_vectorizer_mock
|
||||
),
|
||||
},
|
||||
):
|
||||
with _fake_redisvl_modules(semantic_cache_mock, custom_vectorizer_mock):
|
||||
from litellm.caching.redis_semantic_cache import RedisSemanticCache
|
||||
|
||||
monkeypatch.setenv("REDIS_HOST", "localhost")
|
||||
|
|
|
|||
|
|
@ -128,3 +128,20 @@ class TestGCSBucketBase:
|
|||
assert object_name.endswith("-target_uploadType_media")
|
||||
assert ".." not in object_name
|
||||
assert "?" not in object_name
|
||||
|
||||
|
||||
class TestGCSBucketLoggerBucketName:
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_bucket_name_it_is_constructed_with_survives(self, monkeypatch):
|
||||
"""Reading config.yaml out of a GCS bucket asks for that bucket, not the logging one (LIT-6982)."""
|
||||
monkeypatch.setenv("GCS_BUCKET_NAME", "logging-bucket")
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
|
||||
|
||||
assert GCSBucketLogger(bucket_name="config-bucket").BUCKET_NAME == "config-bucket"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_bucket_name_still_falls_back_to_the_environment(self, monkeypatch):
|
||||
monkeypatch.setenv("GCS_BUCKET_NAME", "logging-bucket")
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
|
||||
|
||||
assert GCSBucketLogger().BUCKET_NAME == "logging-bucket"
|
||||
|
|
|
|||
|
|
@ -3016,3 +3016,62 @@ class TestPreCallHookResponseIsNotLoggedVerbatim:
|
|||
)
|
||||
|
||||
assert self._logged_response(data) == "mask"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_hook_adding_tools_logs_mask(self):
|
||||
class ToolInjectingGuardrail(CustomGuardrail):
|
||||
@log_guardrail_information
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: object,
|
||||
data: dict[str, object],
|
||||
call_type: str,
|
||||
) -> dict[str, object]:
|
||||
return {**data, "tools": [{"type": "function", "function": {"name": "guardrail_injected_tool"}}]}
|
||||
|
||||
data = self._request()
|
||||
await ToolInjectingGuardrail(guardrail_name="g").async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(), cache=None, data=data, call_type="acompletion"
|
||||
)
|
||||
|
||||
assert self._logged_response(data) == "mask"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_adding_tools_logs_mask(self):
|
||||
class ToolInjectingGuardrail(CustomGuardrail):
|
||||
async def apply_guardrail(
|
||||
self,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
request_data: dict[str, object],
|
||||
input_type: Literal["request", "response"],
|
||||
logging_obj: Optional["LiteLLMLoggingObj"] = None,
|
||||
) -> GenericGuardrailAPIInputs:
|
||||
return {**inputs, "tools": [{"type": "function", "function": {"name": "guardrail_injected_tool"}}]}
|
||||
|
||||
data = self._request()
|
||||
await ToolInjectingGuardrail(guardrail_name="g").apply_guardrail(
|
||||
inputs={"texts": ["SECRET_PROMPT"]}, request_data=data, input_type="request"
|
||||
)
|
||||
|
||||
assert self._logged_response(data) == "mask"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_masking_inputs_in_place_logs_mask(self):
|
||||
class InPlaceMaskingGuardrail(CustomGuardrail):
|
||||
async def apply_guardrail(
|
||||
self,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
request_data: dict[str, object],
|
||||
input_type: Literal["request", "response"],
|
||||
logging_obj: Optional["LiteLLMLoggingObj"] = None,
|
||||
) -> GenericGuardrailAPIInputs:
|
||||
inputs["texts"] = ["<REDACTED>"]
|
||||
return inputs
|
||||
|
||||
data = self._request()
|
||||
await InPlaceMaskingGuardrail(guardrail_name="g").apply_guardrail(
|
||||
inputs={"texts": ["SECRET_PROMPT"]}, request_data=data, input_type="request"
|
||||
)
|
||||
|
||||
assert self._logged_response(data) == "mask"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -154,24 +155,22 @@ class TestLangsmithLoggerInit:
|
|||
assert logger._start_periodic_flush_task() is None
|
||||
mock_get_running_loop.assert_called_once()
|
||||
|
||||
@patch("asyncio.get_running_loop")
|
||||
def test_langsmith_init_starts_periodic_flush_with_running_loop(
|
||||
self, mock_get_running_loop
|
||||
):
|
||||
@pytest.mark.asyncio
|
||||
async def test_langsmith_init_starts_periodic_flush_with_running_loop(self):
|
||||
"""Test that init schedules periodic flush when a running loop exists."""
|
||||
mock_loop = MagicMock()
|
||||
mock_task = MagicMock()
|
||||
mock_loop.create_task.return_value = mock_task
|
||||
mock_get_running_loop.return_value = mock_loop
|
||||
|
||||
logger = LangsmithLogger(
|
||||
langsmith_api_key="test-key", langsmith_project="test-project"
|
||||
langsmith_api_key="test-key", langsmith_project="test-project", flush_interval=0.01
|
||||
)
|
||||
batch_sent = asyncio.Event()
|
||||
logger.async_send_batch = AsyncMock(side_effect=batch_sent.set)
|
||||
logger.log_queue.append({"id": "run-id"})
|
||||
|
||||
assert logger._flush_task == mock_task
|
||||
mock_loop.create_task.assert_called_once()
|
||||
scheduled_coro = mock_loop.create_task.call_args.args[0]
|
||||
scheduled_coro.close()
|
||||
flush_task = logger._flush_task
|
||||
assert isinstance(flush_task, asyncio.Task)
|
||||
await asyncio.wait_for(batch_sent.wait(), timeout=5)
|
||||
flush_task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await flush_task
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_log_success_event_lazily_starts_periodic_flush(self):
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ def test_openai_cache_write_tokens_billed_at_the_cache_creation_rate(local_model
|
|||
input_rate = rates["input_cost_per_token"]
|
||||
cache_write_rate = rates["cache_creation_input_token_cost"]
|
||||
output_rate = rates["output_cost_per_token"]
|
||||
assert cache_write_rate == pytest.approx(input_rate * 1.25)
|
||||
assert cache_write_rate > input_rate
|
||||
|
||||
prompt_tokens = 12317
|
||||
cache_write_tokens = 12314
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ from websockets.exceptions import ConnectionClosed
|
|||
from websockets.frames import Close
|
||||
|
||||
import litellm
|
||||
|
||||
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.litellm_core_utils.realtime_streaming import (
|
||||
REALTIME_SESSION_SUCCESS_LOGGED_KEY,
|
||||
|
|
@ -20,10 +18,6 @@ from litellm.litellm_core_utils.realtime_streaming import (
|
|||
)
|
||||
from litellm.llms.xai.realtime.transformation import XAIRealtimeNormalizer
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.llms.openai import (
|
||||
OpenAIRealtimeStreamResponseBaseObject,
|
||||
OpenAIRealtimeStreamSessionEvents,
|
||||
)
|
||||
|
||||
|
||||
def _make_transcript_event(text: str, item_id: str = "item_x") -> bytes:
|
||||
|
|
@ -161,6 +155,7 @@ async def test_backend_to_client_send_text_receives_str_not_bytes():
|
|||
logging_obj = MagicMock()
|
||||
logging_obj.async_success_handler = AsyncMock()
|
||||
logging_obj.success_handler = MagicMock()
|
||||
logging_obj.dispatch_success_handlers = AsyncMock()
|
||||
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
|
||||
|
||||
await streaming.backend_to_client_send_messages()
|
||||
|
|
@ -812,7 +807,6 @@ async def test_transcription_captured_in_backend_to_client():
|
|||
Test that conversation.item.input_audio_transcription.completed events
|
||||
from the backend are captured as user input during the WebSocket session.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
|
@ -838,6 +832,7 @@ async def test_transcription_captured_in_backend_to_client():
|
|||
logging_obj.model_call_details = {"messages": "default-message-value"}
|
||||
logging_obj.async_success_handler = AsyncMock()
|
||||
logging_obj.success_handler = MagicMock()
|
||||
logging_obj.dispatch_success_handlers = AsyncMock()
|
||||
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
|
||||
await streaming.backend_to_client_send_messages()
|
||||
|
||||
|
|
@ -883,6 +878,7 @@ async def test_transcription_session_captures_usage_and_skips_response_create():
|
|||
logging_obj.model_call_details = {}
|
||||
logging_obj.async_success_handler = AsyncMock()
|
||||
logging_obj.success_handler = MagicMock()
|
||||
logging_obj.dispatch_success_handlers = AsyncMock()
|
||||
|
||||
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
|
||||
await streaming.backend_to_client_send_messages()
|
||||
|
|
@ -1100,7 +1096,6 @@ def test_capture_transcription_usage_deduplicates_when_already_stored():
|
|||
When the event is already in messages (logged via store_message), it must not
|
||||
be appended a second time by _capture_transcription_usage.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
streaming = RealTimeStreaming(MagicMock(), MagicMock(), MagicMock())
|
||||
# Add the event type to the default logged list so _should_store_message returns True.
|
||||
|
|
@ -1409,7 +1404,6 @@ async def test_realtime_guardrail_blocks_prompt_injection(monkeypatch: pytest.Mo
|
|||
)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_guardrail_allows_clean_transcript(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
|
|
@ -1466,7 +1460,6 @@ async def test_realtime_guardrail_allows_clean_transcript(monkeypatch: pytest.Mo
|
|||
assert len(response_creates) == 1, f"Clean transcript should trigger response.create, got: {sent_to_backend}"
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_text_input_guardrail_blocks_and_returns_error(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
|
|
@ -1560,7 +1553,6 @@ async def test_realtime_text_input_guardrail_blocks_and_returns_error(monkeypatc
|
|||
assert len(original_items) == 0, f"Blocked item should not be forwarded to backend, got: {original_items}"
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_function_call_output_guardrail_blocks_and_returns_error(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
|
|
@ -1649,7 +1641,6 @@ async def test_realtime_function_call_output_guardrail_blocks_and_returns_error(
|
|||
assert "test@example.com" not in sanitized_item["output"]
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_function_call_output_guardrail_allows_clean_output(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
|
|
@ -1714,7 +1705,6 @@ async def test_realtime_function_call_output_guardrail_allows_clean_output(monke
|
|||
assert len(forwarded) == 1, f"Clean function_call_output should be forwarded, got: {forwarded}"
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_text_input_guardrail_uses_pre_call_mode(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
|
|
@ -1750,7 +1740,6 @@ async def test_realtime_text_input_guardrail_uses_pre_call_mode(monkeypatch: pyt
|
|||
)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_session_created_injects_session_update_for_audio_guardrail(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
|
|
@ -1807,7 +1796,6 @@ async def test_realtime_session_created_injects_session_update_for_audio_guardra
|
|||
)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_session_created_does_not_inject_session_update_for_pre_call_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
@ -1852,7 +1840,6 @@ async def test_realtime_session_created_does_not_inject_session_update_for_pre_c
|
|||
assert len(session_updates) == 0, f"pre_call-only guardrail must not inject session.update, got: {sent_to_backend}"
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_and_post_call_guardrails_do_not_disable_server_vad(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Model Armor-style pre_call + post_call must not gate audio VAD."""
|
||||
|
|
@ -1868,17 +1855,17 @@ async def test_pre_call_and_post_call_guardrails_do_not_disable_server_vad(monke
|
|||
litellm,
|
||||
"callbacks",
|
||||
[
|
||||
ModelArmorStyleGuardrail(
|
||||
guardrail_name="model_armor_all_pre_call",
|
||||
event_hook=GuardrailEventHooks.pre_call,
|
||||
default_on=False,
|
||||
),
|
||||
ModelArmorStyleGuardrail(
|
||||
guardrail_name="model_armor_all_post_call",
|
||||
event_hook=GuardrailEventHooks.post_call,
|
||||
default_on=False,
|
||||
),
|
||||
],
|
||||
ModelArmorStyleGuardrail(
|
||||
guardrail_name="model_armor_all_pre_call",
|
||||
event_hook=GuardrailEventHooks.pre_call,
|
||||
default_on=False,
|
||||
),
|
||||
ModelArmorStyleGuardrail(
|
||||
guardrail_name="model_armor_all_post_call",
|
||||
event_hook=GuardrailEventHooks.post_call,
|
||||
default_on=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
client_ws = MagicMock()
|
||||
|
|
@ -1902,7 +1889,6 @@ async def test_pre_call_and_post_call_guardrails_do_not_disable_server_vad(monke
|
|||
assert streaming._has_audio_transcription_guardrails() is False
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_session_after_n_fails_closes_connection(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
|
|
@ -1949,7 +1935,6 @@ async def test_end_session_after_n_fails_closes_connection(monkeypatch: pytest.M
|
|||
assert streaming._violation_count == 2
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_violation_end_session_closes_on_first_fail(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
|
|
@ -1995,7 +1980,6 @@ async def test_on_violation_end_session_closes_on_first_fail(monkeypatch: pytest
|
|||
assert streaming._violation_count == 1
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_path_suppresses_duplicate_session_created_after_synthetic():
|
||||
client_ws = MagicMock()
|
||||
|
|
@ -2956,7 +2940,9 @@ async def test_log_messages_routes_async_logging_through_bounded_worker():
|
|||
|
||||
mock_worker.ensure_initialized_and_enqueue.assert_called_once()
|
||||
enqueued = mock_worker.ensure_initialized_and_enqueue.call_args
|
||||
assert (enqueued.args or tuple(enqueued.kwargs.values()))[0] is logging_obj.dispatch_success_handlers.return_value
|
||||
assert (enqueued.args or tuple(enqueued.kwargs.values()))[
|
||||
0
|
||||
] is logging_obj.dispatch_success_handlers.return_value
|
||||
logging_obj.dispatch_success_handlers.assert_called_once_with(streaming.messages, prefer_async_handlers=True)
|
||||
logging_obj.success_handler.assert_not_called()
|
||||
# the bare create_task path must no longer be used for success logging
|
||||
|
|
@ -3041,6 +3027,7 @@ async def test_session_close_flushes_unbilled_transcription_usage():
|
|||
logging_obj: Final = MagicMock()
|
||||
logging_obj.async_success_handler = AsyncMock()
|
||||
logging_obj.success_handler = MagicMock()
|
||||
logging_obj.dispatch_success_handlers = AsyncMock()
|
||||
|
||||
usage: Final[RealtimeInputAudioTranscriptionUsage] = {
|
||||
"type": "tokens",
|
||||
|
|
@ -3116,6 +3103,7 @@ async def test_session_close_flush_noop_without_unbilled_usage():
|
|||
logging_obj: Final = MagicMock()
|
||||
logging_obj.async_success_handler = AsyncMock()
|
||||
logging_obj.success_handler = MagicMock()
|
||||
logging_obj.dispatch_success_handlers = AsyncMock()
|
||||
|
||||
provider_config: Final = MagicMock()
|
||||
provider_config.unbilled_usage_on_session_close = MagicMock(return_value=None)
|
||||
|
|
@ -3136,7 +3124,6 @@ async def test_session_close_flush_noop_without_unbilled_usage():
|
|||
)
|
||||
|
||||
|
||||
|
||||
_UPSTREAM_REFUSAL: Final = "Publisher model `publishers/google/models/gemini-live-2.5-flash` was not found"
|
||||
|
||||
|
||||
|
|
@ -3204,9 +3191,7 @@ def _backend_ws_closing_with(*frames: bytes | Exception) -> MagicMock:
|
|||
def _relay_session(client_ws: MagicMock, backend_ws: MagicMock) -> _RelaySession:
|
||||
logging: Final = _RecordingLogging()
|
||||
worker: Final = _InlineLoggingWorker()
|
||||
streaming: Final = RealTimeStreaming(
|
||||
client_ws, backend_ws, logging, model="gpt-realtime", logging_worker=worker
|
||||
)
|
||||
streaming: Final = RealTimeStreaming(client_ws, backend_ws, logging, model="gpt-realtime", logging_worker=worker)
|
||||
return _RelaySession(streaming=streaming, logging=logging, worker=worker)
|
||||
|
||||
|
||||
|
|
@ -3412,3 +3397,136 @@ async def test_refused_session_does_not_stamp_the_reservation_ownership_marker()
|
|||
|
||||
assert session.logging.logged_failures == (upstream_close,)
|
||||
assert REALTIME_SESSION_SUCCESS_LOGGED_KEY not in session.logging.model_call_details
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transformed_transcription_completion_never_sends_response_create():
|
||||
from typing import Final
|
||||
|
||||
completed_event: Final = {
|
||||
"type": "conversation.item.input_audio_transcription.completed",
|
||||
"event_id": "event_1",
|
||||
"item_id": "turn_1",
|
||||
"content_index": 0,
|
||||
"transcript": "private transcript",
|
||||
"usage": {"type": "duration", "seconds": 0.5},
|
||||
}
|
||||
provider_config: Final = MagicMock()
|
||||
provider_config.requires_session_configuration.return_value = True
|
||||
provider_config.transform_realtime_response.return_value = {
|
||||
"response": completed_event,
|
||||
"current_output_item_id": None,
|
||||
"current_response_id": None,
|
||||
"current_delta_chunks": None,
|
||||
"current_conversation_id": None,
|
||||
"current_item_chunks": None,
|
||||
"current_delta_type": None,
|
||||
"session_configuration_request": None,
|
||||
}
|
||||
provider_config.transform_realtime_request.return_value = (json.dumps({"type": "response.create"}),)
|
||||
provider_config.is_setup_message.return_value = False
|
||||
provider_config.is_content_message.return_value = False
|
||||
client_ws: Final = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
backend_ws: Final = MagicMock()
|
||||
backend_ws.send = AsyncMock()
|
||||
|
||||
streaming: Final = RealTimeStreaming(
|
||||
client_ws,
|
||||
backend_ws,
|
||||
MagicMock(),
|
||||
provider_config=provider_config,
|
||||
model="muse-voice-transcribe-1.0",
|
||||
force_transcription_model="muse-voice-transcribe-1.0",
|
||||
)
|
||||
|
||||
await streaming._handle_provider_config_message("{}")
|
||||
|
||||
assert json.loads(client_ws.send_text.await_args.args[0]) == completed_event
|
||||
backend_ws.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcription_session_still_runs_transcription_guardrail(monkeypatch: pytest.MonkeyPatch):
|
||||
class BlockingGuardrail(CustomGuardrail):
|
||||
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
|
||||
raise ValueError("blocked transcript")
|
||||
|
||||
guardrail: Final = BlockingGuardrail(
|
||||
guardrail_name="transcription-blocker",
|
||||
event_hook=GuardrailEventHooks.realtime_input_transcription,
|
||||
default_on=True,
|
||||
)
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
completed_event: Final = {
|
||||
"type": "conversation.item.input_audio_transcription.completed",
|
||||
"event_id": "event_1",
|
||||
"item_id": "turn_1",
|
||||
"content_index": 0,
|
||||
"transcript": "blocked transcript",
|
||||
"usage": {"type": "duration", "seconds": 0.5},
|
||||
}
|
||||
provider_config: Final = MagicMock()
|
||||
provider_config.requires_session_configuration.return_value = True
|
||||
provider_config.transform_realtime_response.return_value = {
|
||||
"response": completed_event,
|
||||
"current_output_item_id": None,
|
||||
"current_response_id": None,
|
||||
"current_delta_chunks": None,
|
||||
"current_conversation_id": None,
|
||||
"current_item_chunks": None,
|
||||
"current_delta_type": None,
|
||||
"session_configuration_request": None,
|
||||
}
|
||||
provider_config.transform_realtime_request.return_value = ()
|
||||
provider_config.is_setup_message.return_value = False
|
||||
provider_config.is_content_message.return_value = False
|
||||
client_ws: Final = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
backend_ws: Final = MagicMock()
|
||||
backend_ws.send = AsyncMock()
|
||||
|
||||
streaming: Final = RealTimeStreaming(
|
||||
client_ws,
|
||||
backend_ws,
|
||||
MagicMock(),
|
||||
provider_config=provider_config,
|
||||
model="muse-voice-transcribe-1.0",
|
||||
force_transcription_model="muse-voice-transcribe-1.0",
|
||||
)
|
||||
|
||||
await streaming._handle_provider_config_message("{}")
|
||||
|
||||
sent_to_client: Final = [json.loads(call.args[0]) for call in client_ws.send_text.await_args_list]
|
||||
assert completed_event in sent_to_client
|
||||
error_events: Final = [event for event in sent_to_client if event.get("type") == "error"]
|
||||
assert len(error_events) == 1
|
||||
assert error_events[0]["error"]["type"] == "guardrail_violation"
|
||||
backend_ws.send.assert_not_awaited()
|
||||
assert streaming._violation_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_bytes_are_sent_raw_after_pacing():
|
||||
from typing import Final
|
||||
|
||||
backend_ws: Final = MagicMock()
|
||||
backend_ws.send = AsyncMock()
|
||||
provider_config: Final = MagicMock()
|
||||
provider_config.requires_session_configuration.return_value = True
|
||||
provider_config.transform_realtime_request.return_value = (b"\x00\x01", '{"type":"endStream"}')
|
||||
provider_config.pace_backend_send = AsyncMock()
|
||||
provider_config.is_setup_message.return_value = False
|
||||
streaming: Final = RealTimeStreaming(
|
||||
MagicMock(),
|
||||
backend_ws,
|
||||
MagicMock(),
|
||||
provider_config=provider_config,
|
||||
model="muse-voice-transcribe-1.0",
|
||||
)
|
||||
|
||||
assert await streaming._send_to_backend(json.dumps({"type": "input_audio_buffer.commit"})) is True
|
||||
|
||||
assert [call.args[0] for call in backend_ws.send.await_args_list] == [b"\x00\x01", '{"type":"endStream"}']
|
||||
provider_config.pace_backend_send.assert_awaited_once_with(b"\x00\x01")
|
||||
|
|
|
|||
|
|
@ -1246,7 +1246,7 @@ async def _flush_logging_worker(capture: "_SuccessPayloadCapture") -> None:
|
|||
await asyncio.sleep(0)
|
||||
try:
|
||||
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10.0)
|
||||
except (asyncio.TimeoutError, RuntimeError):
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
deadline = asyncio.get_running_loop().time() + 10.0
|
||||
while not capture.payloads and asyncio.get_running_loop().time() < deadline:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -824,6 +825,174 @@ async def test_async_sse_wrapper_bills_partial_when_detached_drains_disabled(mon
|
|||
assert len(streaming_iterator_module._DETACHED_STREAM_DRAINS) == 0
|
||||
|
||||
|
||||
class _SuccessRecorder(CustomLogger):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.success_kwargs: list = []
|
||||
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self.success_kwargs.append(kwargs)
|
||||
|
||||
|
||||
def _make_priced_logging_obj(call_id: str, recorder: _SuccessRecorder, model: str) -> LiteLLMLoggingObj:
|
||||
logging_obj = LiteLLMLoggingObj(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="anthropic_messages",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id=call_id,
|
||||
function_id=call_id,
|
||||
dynamic_async_success_callbacks=[recorder],
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
model=model,
|
||||
user="",
|
||||
optional_params={},
|
||||
litellm_params={"custom_llm_provider": "anthropic"},
|
||||
custom_llm_provider="anthropic",
|
||||
)
|
||||
return logging_obj
|
||||
|
||||
|
||||
class _UpstreamClosedOnDetach:
|
||||
"""Upstream that yields its events and then, like a socket read, waits until it is closed."""
|
||||
|
||||
def __init__(self, events: tuple[dict, ...]):
|
||||
self._events = iter(events)
|
||||
self._closed = asyncio.Event()
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> dict:
|
||||
if self._closed.is_set():
|
||||
raise StopAsyncIteration
|
||||
try:
|
||||
return next(self._events)
|
||||
except StopIteration:
|
||||
await self._closed.wait()
|
||||
raise StopAsyncIteration
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self._closed.set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_disconnect_partial_billing_prices_recovered_tokens(monkeypatch):
|
||||
"""
|
||||
Regression (LIT-6872): a client disconnect that lands on partial billing
|
||||
re-tokenizes the buffered text into completion_tokens, but the logged cost
|
||||
stayed priced at the message_start placeholder (1 output token). The success
|
||||
row's response_cost must match its recovered completion_tokens.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
||||
monkeypatch.setattr(streaming_iterator_module, "ANTHROPIC_MESSAGES_MAX_DETACHED_STREAM_DRAINS", 0)
|
||||
monkeypatch.setattr(streaming_iterator_module, "ANTHROPIC_MESSAGES_STREAM_RELAY_QUEUE_MAXSIZE", 4)
|
||||
model = "claude-sonnet-5"
|
||||
recorder = _SuccessRecorder()
|
||||
iterator = BaseAnthropicMessagesStreamingIterator(
|
||||
litellm_logging_obj=_make_priced_logging_obj("disconnect_partial_cost", recorder, model),
|
||||
request_body={"model": model, "stream": True},
|
||||
)
|
||||
sentence = "The history of computing spans centuries of mechanical and electronic invention. "
|
||||
|
||||
async def _stream():
|
||||
yield {"type": "message_start", "message": {"id": "msg_1", "usage": {"input_tokens": 29, "output_tokens": 1}}}
|
||||
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
|
||||
for _ in range(100):
|
||||
yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": sentence}}
|
||||
yield {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 1500}}
|
||||
yield {"type": "message_stop"}
|
||||
|
||||
enqueued: list = []
|
||||
|
||||
def _capture(async_coroutine):
|
||||
enqueued.append(async_coroutine)
|
||||
|
||||
with patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam
|
||||
GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue", side_effect=_capture
|
||||
):
|
||||
gen = iterator.async_sse_wrapper(_stream())
|
||||
for _ in range(4):
|
||||
await gen.__anext__()
|
||||
await gen.aclose()
|
||||
for _ in range(500):
|
||||
if enqueued:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert len(enqueued) == 1, "client disconnect never reached partial billing"
|
||||
await enqueued[0]
|
||||
|
||||
assert len(recorder.success_kwargs) == 1
|
||||
logged = recorder.success_kwargs[0]["standard_logging_object"]
|
||||
assert 1 < logged["completion_tokens"] < 1500
|
||||
prompt_cost, completion_cost = litellm.cost_per_token(
|
||||
model=model, prompt_tokens=29, completion_tokens=logged["completion_tokens"]
|
||||
)
|
||||
assert logged["response_cost"] == pytest.approx(prompt_cost + completion_cost)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_disconnect_closing_upstream_prices_recovered_tokens():
|
||||
"""
|
||||
Regression (LIT-6872), proxy path: after a client disconnect the proxy's
|
||||
shielded cleanup closes the upstream stream while the pump is still reading
|
||||
it, so the pump bills the chunks collected so far without ever seeing
|
||||
message_delta. That row's response_cost must be priced from its recovered
|
||||
completion_tokens, not from the message_start placeholder.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
||||
model = "claude-sonnet-5"
|
||||
recorder = _SuccessRecorder()
|
||||
iterator = BaseAnthropicMessagesStreamingIterator(
|
||||
litellm_logging_obj=_make_priced_logging_obj("disconnect_upstream_closed", recorder, model),
|
||||
request_body={"model": model, "stream": True},
|
||||
)
|
||||
sentence = "The history of computing spans centuries of mechanical and electronic invention. "
|
||||
upstream = _UpstreamClosedOnDetach(
|
||||
(
|
||||
{"type": "message_start", "message": {"id": "msg_1", "usage": {"input_tokens": 29, "output_tokens": 1}}},
|
||||
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
||||
*({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": sentence}} for _ in range(6)),
|
||||
)
|
||||
)
|
||||
enqueued: list = []
|
||||
|
||||
def _capture(async_coroutine):
|
||||
enqueued.append(async_coroutine)
|
||||
|
||||
with patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam
|
||||
GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue", side_effect=_capture
|
||||
):
|
||||
gen = iterator.async_sse_wrapper(upstream)
|
||||
for _ in range(4):
|
||||
await gen.__anext__()
|
||||
await gen.aclose()
|
||||
assert not enqueued, "billing must wait for the upstream read to end, not the client detach"
|
||||
await upstream.aclose()
|
||||
for _ in range(500):
|
||||
if enqueued:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert len(enqueued) == 1, "closing the upstream never reached partial billing"
|
||||
await enqueued[0]
|
||||
|
||||
assert len(recorder.success_kwargs) == 1
|
||||
logged = recorder.success_kwargs[0]["standard_logging_object"]
|
||||
assert logged["completion_tokens"] > 1
|
||||
prompt_cost, completion_cost = litellm.cost_per_token(
|
||||
model=model, prompt_tokens=29, completion_tokens=logged["completion_tokens"]
|
||||
)
|
||||
assert logged["response_cost"] == pytest.approx(prompt_cost + completion_cost)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_sse_wrapper_aborts_upstream_when_detached_drain_cap_reached(monkeypatch):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,683 @@
|
|||
import base64
|
||||
import itertools
|
||||
import json
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.llms.meta.realtime.transformation import (
|
||||
DEFAULT_MUSE_REALTIME_URL,
|
||||
MUSE_MODEL,
|
||||
MetaRealtimeConfig,
|
||||
MuseEventTransformer,
|
||||
MuseProtocolError,
|
||||
MuseSessionConfig,
|
||||
build_muse_realtime_url,
|
||||
normalize_access_token,
|
||||
normalize_language,
|
||||
parse_session_update,
|
||||
session_created_event,
|
||||
)
|
||||
from litellm.types.llms.meta import MuseMode
|
||||
from litellm.types.realtime import RealtimeResponseTransformInput
|
||||
|
||||
EMPTY_TRANSFORM_INPUT: Final[RealtimeResponseTransformInput] = {
|
||||
"session_configuration_request": None,
|
||||
"current_output_item_id": None,
|
||||
"current_response_id": None,
|
||||
"current_delta_chunks": None,
|
||||
"current_item_chunks": None,
|
||||
"current_conversation_id": None,
|
||||
"current_delta_type": None,
|
||||
}
|
||||
|
||||
|
||||
def _event(event_type: str, **fields: object) -> str:
|
||||
return json.dumps({"type": event_type, **fields})
|
||||
|
||||
|
||||
def _ga_session_update(rate: int = 24_000, turn_detection: object = "server_vad") -> str:
|
||||
return _event(
|
||||
"session.update",
|
||||
session={
|
||||
"type": "transcription",
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": {"type": "audio/pcm", "rate": rate},
|
||||
"turn_detection": None if turn_detection is None else {"type": turn_detection},
|
||||
"transcription": {"model": f"meta/{MUSE_MODEL}"},
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _configured(rate: int = 24_000, turn_detection: object = "server_vad", **kwargs: object) -> MetaRealtimeConfig:
|
||||
config = MetaRealtimeConfig(**kwargs)
|
||||
config.validate_environment({}, MUSE_MODEL, api_key="secret-token")
|
||||
config.transform_realtime_request(_ga_session_update(rate, turn_detection), MUSE_MODEL)
|
||||
return config
|
||||
|
||||
|
||||
def _backend_events(config: MetaRealtimeConfig, payload: str) -> list[dict[str, object]]:
|
||||
response = config.transform_realtime_response(payload, MUSE_MODEL, MagicMock(), EMPTY_TRANSFORM_INPUT)["response"]
|
||||
assert isinstance(response, list)
|
||||
return response
|
||||
|
||||
|
||||
def test_beta_session_translates_language_and_drops_non_openai_hints():
|
||||
config = parse_session_update(
|
||||
_event(
|
||||
"session.update",
|
||||
session={
|
||||
"type": "transcription",
|
||||
"input_audio_format": "pcm16",
|
||||
"turn_detection": {"type": "server_vad"},
|
||||
"input_audio_transcription": {
|
||||
"model": "meta/muse-voice-transcribe-1.0",
|
||||
"language": "en-US",
|
||||
"prompt": "must not become a keyword",
|
||||
},
|
||||
},
|
||||
),
|
||||
"meta/muse-voice-transcribe-1.0",
|
||||
)
|
||||
|
||||
assert config.sample_rate == 24_000
|
||||
assert config.packet_bytes == 3_840
|
||||
assert config.mode == "ENDPOINTING"
|
||||
assert config.language_bias == ("English",)
|
||||
assert config.handshake("Bearer token") == {
|
||||
"mode": "ENDPOINTING",
|
||||
"authorization": {"accessToken": "Bearer token"},
|
||||
"audioEncoding": "PCM_24KHZ",
|
||||
"model": MUSE_MODEL,
|
||||
"partialMode": "CUMULATIVE",
|
||||
"emitAudioProgress": True,
|
||||
"languageBias": ("English",),
|
||||
}
|
||||
assert "must not become a keyword" not in json.dumps(config.handshake("Bearer token"))
|
||||
|
||||
|
||||
def test_ga_session_accepts_16khz_mono_push_to_talk():
|
||||
config = parse_session_update(
|
||||
_event(
|
||||
"session.update",
|
||||
session={
|
||||
"type": "transcription",
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": {"type": "audio/pcm", "rate": 16000, "channels": 1},
|
||||
"turn_detection": None,
|
||||
"transcription": {"model": MUSE_MODEL, "language": "zh-Hans"},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
MUSE_MODEL,
|
||||
)
|
||||
|
||||
assert config.sample_rate == 16_000
|
||||
assert config.packet_bytes == 2_560
|
||||
assert config.mode == "PUSH_TO_TALK"
|
||||
assert config.language_bias == ("Mandarin Chinese",)
|
||||
assert config.handshake("Bearer token")["audioEncoding"] == "PCM_16KHZ"
|
||||
assert "languageBias" not in MuseSessionConfig(MUSE_MODEL, "ENDPOINTING", 24_000, ()).handshake("Bearer token")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
("EN_us", "English"),
|
||||
("mandarin chinese", "Mandarin Chinese"),
|
||||
("fil-PH", "Tagalog"),
|
||||
("iw-IL", "Hebrew"),
|
||||
("pt-BR", "Portuguese"),
|
||||
],
|
||||
)
|
||||
def test_language_normalization_uses_official_muse_names(source: str, expected: str):
|
||||
assert normalize_language(source) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("session", "message"),
|
||||
[
|
||||
({"input_audio_format": "g711_ulaw"}, "requires pcm16"),
|
||||
({"audio": {"input": {"format": {"type": "audio/pcm", "rate": 8000}}}}, "16000 Hz or 24000 Hz"),
|
||||
(
|
||||
{"audio": {"input": {"format": {"type": "audio/pcm", "rate": 24000, "channels": 2}}}},
|
||||
"requires mono",
|
||||
),
|
||||
(
|
||||
{"input_audio_format": "pcm16", "audio": {"input": {"format": {"type": "audio/pcm"}}}},
|
||||
"either beta or GA layout",
|
||||
),
|
||||
({"input_audio_transcription": {"model": "other-model"}}, "cannot be changed"),
|
||||
({"input_audio_transcription": {"language": "xx"}}, "unsupported Muse Voice language"),
|
||||
({"turn_detection": {"type": "semantic_vad"}}, "server_vad turn detection or null"),
|
||||
({"type": "realtime", "audio": {"input": {"turn_detection": {"type": "semantic_vad"}}}}, "server_vad"),
|
||||
],
|
||||
)
|
||||
def test_session_rejects_unsupported_audio_model_and_hints(session: dict[str, object], message: str):
|
||||
with pytest.raises(MuseProtocolError, match=message):
|
||||
parse_session_update(_event("session.update", session={"type": "transcription", **session}), MUSE_MODEL)
|
||||
|
||||
|
||||
def test_session_created_event_exposes_openai_transcription_shape():
|
||||
config = parse_session_update(
|
||||
_event(
|
||||
"session.update",
|
||||
session={
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": {"type": "audio/pcm", "rate": 24000},
|
||||
"transcription": {"model": MUSE_MODEL, "language": "ja"},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
MUSE_MODEL,
|
||||
)
|
||||
|
||||
created = session_created_event(config, "provider-session")
|
||||
|
||||
assert created["type"] == "session.created"
|
||||
assert created["session"]["id"] == "provider-session"
|
||||
assert created["session"]["type"] == "transcription"
|
||||
assert created["session"]["audio"]["input"]["turn_detection"] == {"type": "server_vad"}
|
||||
assert created["session"]["audio"]["input"]["transcription"] == {"model": MUSE_MODEL, "language": "Japanese"}
|
||||
|
||||
|
||||
def test_turnless_empty_silence_transcript_is_ignored():
|
||||
transformer = MuseEventTransformer()
|
||||
|
||||
assert transformer.transform(json.loads(_event("transcript", transcript="", final=True))) == ()
|
||||
|
||||
|
||||
def test_transcript_without_speech_start_synthesizes_start_before_delta():
|
||||
transformer = MuseEventTransformer()
|
||||
|
||||
events = transformer.transform(json.loads(_event("transcript", turnId="turn-1", transcript="hello", final=False)))
|
||||
|
||||
assert [event["type"] for event in events] == [
|
||||
"input_audio_buffer.speech_started",
|
||||
"conversation.item.input_audio_transcription.delta",
|
||||
]
|
||||
|
||||
|
||||
def test_cumulative_partials_emit_only_extensions_and_final_is_authoritative():
|
||||
transformer = MuseEventTransformer()
|
||||
|
||||
def send(payload: str) -> tuple[dict[str, object], ...]:
|
||||
return transformer.transform(json.loads(payload))
|
||||
|
||||
started = send(_event("speechStart", turnId="turn-1"))
|
||||
first = send(_event("transcript", turnId="turn-1", transcript="hello", final=False))
|
||||
extension = send(_event("transcript", turnId="turn-1", transcript="hello world", final=False))
|
||||
rewrite = send(_event("transcript", turnId="turn-1", transcript="hullo world", final=False))
|
||||
completed = send(_event("speechComplete", turnId="turn-1", transcript="hullo world"))
|
||||
|
||||
assert [event["type"] for event in started] == ["input_audio_buffer.speech_started"]
|
||||
assert first[0]["delta"] == "hello"
|
||||
assert extension[0]["delta"] == " world"
|
||||
assert rewrite == ()
|
||||
assert completed[0]["type"] == "input_audio_buffer.speech_stopped"
|
||||
assert completed[1]["type"] == "conversation.item.input_audio_transcription.completed"
|
||||
assert completed[1]["item_id"] == "turn-1"
|
||||
assert completed[1]["transcript"] == "hullo world"
|
||||
assert send(_event("speechEnd", turnId="turn-1")) == ()
|
||||
|
||||
|
||||
def test_speech_end_then_speech_complete_emits_stopped_then_completed():
|
||||
transformer = MuseEventTransformer()
|
||||
|
||||
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
|
||||
stopped = transformer.transform(json.loads(_event("speechEnd", turnId="turn-1")))
|
||||
completed = transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="done")))
|
||||
|
||||
assert [event["type"] for event in stopped] == ["input_audio_buffer.speech_stopped"]
|
||||
assert [event["type"] for event in completed] == ["conversation.item.input_audio_transcription.completed"]
|
||||
assert completed[0]["transcript"] == "done"
|
||||
|
||||
|
||||
def test_turnless_partial_between_speech_end_and_speech_complete_stays_on_that_turn():
|
||||
transformer = MuseEventTransformer()
|
||||
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
|
||||
transformer.transform(json.loads(_event("transcript", transcript="what is", final=False)))
|
||||
transformer.transform(json.loads(_event("speechEnd", turnId="turn-1")))
|
||||
|
||||
post_processed = transformer.transform(
|
||||
json.loads(_event("transcript", transcript="what is the weather", final=False))
|
||||
)
|
||||
completed = transformer.transform(
|
||||
json.loads(_event("speechComplete", turnId="turn-1", transcript="What is the weather?"))
|
||||
)
|
||||
|
||||
assert _typed(post_processed) == [("conversation.item.input_audio_transcription.delta", "turn-1")]
|
||||
assert post_processed[0]["delta"] == " the weather"
|
||||
assert _typed(completed) == [("conversation.item.input_audio_transcription.completed", "turn-1")]
|
||||
assert completed[0]["transcript"] == "What is the weather?"
|
||||
|
||||
|
||||
def _typed(events: tuple[dict[str, object], ...]) -> list[tuple[object, object]]:
|
||||
return [(event["type"], event["item_id"]) for event in events]
|
||||
|
||||
|
||||
def test_overlapping_turns_emit_independently_and_correlate_by_item_id():
|
||||
transformer = MuseEventTransformer()
|
||||
|
||||
def send(payload: str) -> list[tuple[object, object]]:
|
||||
return _typed(transformer.transform(json.loads(payload)))
|
||||
|
||||
assert send(_event("speechStart", turnId="turn-a")) == [("input_audio_buffer.speech_started", "turn-a")]
|
||||
assert send(_event("speechStart", turnId="turn-b")) == [("input_audio_buffer.speech_started", "turn-b")]
|
||||
assert send(_event("transcript", turnId="turn-b", transcript="second", final=False)) == [
|
||||
("conversation.item.input_audio_transcription.delta", "turn-b")
|
||||
]
|
||||
assert send(_event("speechComplete", turnId="turn-a", transcript="first")) == [
|
||||
("input_audio_buffer.speech_stopped", "turn-a"),
|
||||
("conversation.item.input_audio_transcription.completed", "turn-a"),
|
||||
]
|
||||
assert send(_event("speechEnd", turnId="turn-a")) == []
|
||||
assert send(_event("speechEnd", turnId="turn-b")) == [("input_audio_buffer.speech_stopped", "turn-b")]
|
||||
assert send(_event("speechComplete", turnId="turn-b", transcript="second final")) == [
|
||||
("conversation.item.input_audio_transcription.completed", "turn-b")
|
||||
]
|
||||
|
||||
|
||||
def test_empty_vad_turn_is_closed_and_does_not_block_the_next_turn():
|
||||
transformer = MuseEventTransformer()
|
||||
|
||||
def send(payload: str) -> list[tuple[object, object]]:
|
||||
return _typed(transformer.transform(json.loads(payload)))
|
||||
|
||||
assert send(_event("speechStart", turnId="noise")) == [("input_audio_buffer.speech_started", "noise")]
|
||||
assert send(_event("speechEnd", turnId="noise")) == [("input_audio_buffer.speech_stopped", "noise")]
|
||||
assert send(_event("speechStart", turnId="speech")) == [("input_audio_buffer.speech_started", "speech")]
|
||||
assert send(_event("transcript", turnId="speech", transcript="hello", final=False)) == [
|
||||
("conversation.item.input_audio_transcription.delta", "speech")
|
||||
]
|
||||
assert send(_event("speechEnd", turnId="speech")) == [("input_audio_buffer.speech_stopped", "speech")]
|
||||
assert send(_event("speechComplete", turnId="speech", transcript="hello world")) == [
|
||||
("conversation.item.input_audio_transcription.completed", "speech")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transcript", ["", "late words"])
|
||||
def test_late_speech_complete_after_an_empty_speech_end_completes_that_item(transcript: str):
|
||||
transformer = MuseEventTransformer()
|
||||
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
|
||||
transformer.transform(json.loads(_event("speechEnd", turnId="turn-1")))
|
||||
transformer.transform(json.loads(_event("speechStart", turnId="turn-2")))
|
||||
|
||||
(completed,) = transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript=transcript)))
|
||||
|
||||
assert completed["type"] == "conversation.item.input_audio_transcription.completed"
|
||||
assert completed["item_id"] == "turn-1"
|
||||
assert completed["transcript"] == transcript
|
||||
|
||||
|
||||
def test_push_to_talk_speech_complete_closes_the_turn_without_speech_end():
|
||||
transformer = MuseEventTransformer()
|
||||
transformer.configure(MuseSessionConfig(MUSE_MODEL, "PUSH_TO_TALK", 24_000, ()))
|
||||
|
||||
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
|
||||
transformer.transform(json.loads(_event("transcript", turnId="turn-1", transcript="hel", final=False)))
|
||||
events = transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="hello")))
|
||||
|
||||
assert [event["type"] for event in events] == [
|
||||
"input_audio_buffer.speech_stopped",
|
||||
"conversation.item.input_audio_transcription.completed",
|
||||
]
|
||||
assert events[1]["transcript"] == "hello"
|
||||
|
||||
|
||||
_TERMINAL_SIGNALS: Final = {
|
||||
"speechEnd": _event("speechEnd", turnId="turn-1"),
|
||||
"speechComplete": _event("speechComplete", turnId="turn-1", transcript="final words"),
|
||||
"final": _event("transcript", turnId="turn-1", transcript="final words", final=True),
|
||||
}
|
||||
_TERMINAL_ORDERINGS: Final = tuple(
|
||||
ordering for size in (1, 2, 3) for ordering in itertools.permutations(_TERMINAL_SIGNALS, size)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["ENDPOINTING", "PUSH_TO_TALK"])
|
||||
@pytest.mark.parametrize("ordering", _TERMINAL_ORDERINGS, ids="-".join)
|
||||
def test_every_terminal_signal_order_closes_the_turn_exactly_once(mode: MuseMode, ordering: tuple[str, ...]):
|
||||
transformer = MuseEventTransformer()
|
||||
transformer.configure(MuseSessionConfig(MUSE_MODEL, mode, 24_000, ()))
|
||||
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
|
||||
transformer.transform(json.loads(_event("transcript", turnId="turn-1", transcript="fin", final=False)))
|
||||
|
||||
emitted = [
|
||||
event["type"] for signal in ordering for event in transformer.transform(json.loads(_TERMINAL_SIGNALS[signal]))
|
||||
]
|
||||
replayed = [
|
||||
event["type"] for signal in ordering for event in transformer.transform(json.loads(_TERMINAL_SIGNALS[signal]))
|
||||
]
|
||||
|
||||
has_text = bool(set(ordering) & {"speechComplete", "final"})
|
||||
assert emitted == [
|
||||
"input_audio_buffer.speech_stopped",
|
||||
*(["conversation.item.input_audio_transcription.completed"] if has_text else []),
|
||||
]
|
||||
assert replayed == []
|
||||
|
||||
|
||||
def test_push_to_talk_final_transcript_completes_without_speech_end():
|
||||
transformer = MuseEventTransformer()
|
||||
transformer.configure(MuseSessionConfig(MUSE_MODEL, "PUSH_TO_TALK", 24_000, ()))
|
||||
|
||||
events = transformer.transform(json.loads(_event("transcript", transcript="hello there", final=True)))
|
||||
|
||||
assert [event["type"] for event in events] == [
|
||||
"input_audio_buffer.speech_started",
|
||||
"input_audio_buffer.speech_stopped",
|
||||
"conversation.item.input_audio_transcription.completed",
|
||||
]
|
||||
assert events[2]["transcript"] == "hello there"
|
||||
assert str(events[0]["item_id"]).startswith("item_")
|
||||
|
||||
|
||||
def test_positive_audio_progress_deltas_attach_to_next_completion_and_speaker_is_ignored():
|
||||
transformer = MuseEventTransformer()
|
||||
|
||||
def send(payload: str) -> tuple[dict[str, object], ...]:
|
||||
return transformer.transform(json.loads(payload))
|
||||
|
||||
send(_event("audioProgress", audioProcessedMs=1000))
|
||||
send(_event("audioProgress", audioProcessedMs=750))
|
||||
send(_event("audioProgress", audioProcessedMs=1600))
|
||||
assert send(_event("speaker", turnId=42, label=" Speaker 2 ")) == ()
|
||||
completed = send(_event("speechComplete", turnId=42, transcript="hello"))
|
||||
|
||||
assert "speaker" not in completed[-1]
|
||||
assert completed[-1]["usage"] == {"type": "duration", "seconds": 1.6}
|
||||
assert transformer.take_unbilled_usage() is None
|
||||
assert send(_event("speechEnd", turnId=42)) == ()
|
||||
|
||||
|
||||
def test_trailing_audio_progress_is_returned_once():
|
||||
transformer = MuseEventTransformer()
|
||||
|
||||
transformer.transform(json.loads(_event("audioProgress", audioProcessedMs=250)))
|
||||
|
||||
assert transformer.take_unbilled_usage() == {"type": "duration", "seconds": 0.25}
|
||||
assert transformer.take_unbilled_usage() is None
|
||||
|
||||
|
||||
def test_finished_turn_ignores_late_duplicates():
|
||||
transformer = MuseEventTransformer()
|
||||
|
||||
released = transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="done")))
|
||||
|
||||
assert [event["type"] for event in released] == [
|
||||
"input_audio_buffer.speech_started",
|
||||
"input_audio_buffer.speech_stopped",
|
||||
"conversation.item.input_audio_transcription.completed",
|
||||
]
|
||||
assert transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="duplicate"))) == ()
|
||||
assert transformer.transform(json.loads(_event("speechEnd", turnId="turn-1"))) == ()
|
||||
assert transformer.transform(json.loads(_event("speechStart", turnId="turn-1"))) == ()
|
||||
assert (
|
||||
transformer.transform(json.loads(_event("transcript", turnId="turn-1", transcript="late", final=False))) == ()
|
||||
)
|
||||
|
||||
|
||||
def test_late_duplicate_speech_start_does_not_capture_the_next_turnless_transcript():
|
||||
transformer = MuseEventTransformer()
|
||||
transformer.configure(MuseSessionConfig(MUSE_MODEL, "PUSH_TO_TALK", 24_000, ()))
|
||||
transformer.transform(json.loads(_event("speechStart", turnId="turn-1")))
|
||||
transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="first")))
|
||||
|
||||
assert transformer.transform(json.loads(_event("speechStart", turnId="turn-1"))) == ()
|
||||
events = transformer.transform(json.loads(_event("transcript", transcript="second", final=True)))
|
||||
|
||||
assert [event["type"] for event in events] == [
|
||||
"input_audio_buffer.speech_started",
|
||||
"input_audio_buffer.speech_stopped",
|
||||
"conversation.item.input_audio_transcription.completed",
|
||||
]
|
||||
assert events[2]["transcript"] == "second"
|
||||
assert events[2]["item_id"] != "turn-1"
|
||||
|
||||
|
||||
def test_turn_memory_is_bounded_by_turn_limit():
|
||||
transformer = MuseEventTransformer(turn_limit=2)
|
||||
|
||||
transformer.transform(json.loads(_event("speechComplete", turnId="turn-1", transcript="one")))
|
||||
transformer.transform(json.loads(_event("speechComplete", turnId="turn-2", transcript="two")))
|
||||
assert transformer.transform(json.loads(_event("speechEnd", turnId="turn-1"))) == ()
|
||||
transformer.transform(json.loads(_event("speechComplete", turnId="turn-3", transcript="three")))
|
||||
|
||||
forgotten = transformer.transform(json.loads(_event("speechEnd", turnId="turn-1")))
|
||||
|
||||
assert [event["type"] for event in forgotten] == ["input_audio_buffer.speech_stopped"]
|
||||
|
||||
|
||||
def test_provider_error_is_sanitized_and_encodable():
|
||||
token = "private-token"
|
||||
provider_body = f"authorization failed for Bearer {token}"
|
||||
transformed = MuseEventTransformer().transform(
|
||||
json.loads(_event("error", code="AUTH", message=provider_body, request={"accessToken": token}))
|
||||
)
|
||||
|
||||
encoded = json.dumps(transformed[0])
|
||||
assert json.loads(encoded)["error"] == {
|
||||
"type": "server_error",
|
||||
"message": "Meta Muse realtime transcription failed",
|
||||
}
|
||||
assert token not in encoded
|
||||
assert provider_body not in encoded
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[("token", "Bearer token"), (" Bearer token ", "Bearer token"), ("bearer token", "Bearer token")],
|
||||
)
|
||||
def test_access_token_normalization_adds_single_bearer_prefix(raw: str, expected: str):
|
||||
assert normalize_access_token(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", " ", "Bearer", " bearer "])
|
||||
def test_access_token_normalization_rejects_empty_tokens(raw: str):
|
||||
with pytest.raises(ValueError, match=r"token|key is required"):
|
||||
normalize_access_token(raw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_base", "expected"),
|
||||
[
|
||||
(None, DEFAULT_MUSE_REALTIME_URL),
|
||||
("https://example.test/custom/path?ignored=yes", "wss://example.test/v1/asr/realtime"),
|
||||
("wss://example.test:8443/other", "wss://example.test:8443/v1/asr/realtime"),
|
||||
],
|
||||
)
|
||||
def test_realtime_url_pins_muse_path(api_base: str | None, expected: str):
|
||||
assert build_muse_realtime_url(api_base) == expected
|
||||
assert MetaRealtimeConfig().get_complete_url(api_base, f"meta/{MUSE_MODEL}") == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_base",
|
||||
[
|
||||
"http://example.test",
|
||||
"ws://example.test",
|
||||
"wss://user:pass@example.test",
|
||||
"wss://example.test/path#fragment",
|
||||
"not-a-url",
|
||||
],
|
||||
)
|
||||
def test_realtime_url_rejects_insecure_or_ambiguous_bases(api_base: str):
|
||||
with pytest.raises(ValueError, match="absolute wss:// or https://"):
|
||||
build_muse_realtime_url(api_base)
|
||||
|
||||
|
||||
def test_unsupported_model_is_rejected_before_connecting():
|
||||
with pytest.raises(ValueError, match="Unsupported Meta realtime model: meta/other-model"):
|
||||
MetaRealtimeConfig().get_complete_url(None, "meta/other-model")
|
||||
|
||||
|
||||
def test_missing_api_key_is_rejected(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("META_API_KEY", raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match="api_key is required for Meta API calls"):
|
||||
MetaRealtimeConfig().validate_environment({}, MUSE_MODEL)
|
||||
|
||||
|
||||
def test_bearer_token_travels_only_in_the_json_handshake():
|
||||
config = MetaRealtimeConfig()
|
||||
headers = {"x-existing": "kept"}
|
||||
|
||||
assert config.validate_environment(headers, MUSE_MODEL, api_key="secret-token") == {"x-existing": "kept"}
|
||||
(handshake,) = config.transform_realtime_request(_ga_session_update(), MUSE_MODEL)
|
||||
|
||||
assert isinstance(handshake, str)
|
||||
assert json.loads(handshake)["authorization"] == {"accessToken": "Bearer secret-token"}
|
||||
assert config.is_setup_message(json.loads(handshake)) is True
|
||||
assert config.is_setup_message({"type": "input_audio_buffer.append"}) is False
|
||||
assert config.transform_realtime_request(_ga_session_update(), MUSE_MODEL) == ()
|
||||
|
||||
|
||||
def test_synthetic_session_created_uses_default_transcription_shape():
|
||||
created = MetaRealtimeConfig().transform_session_created_event(f"meta/{MUSE_MODEL}", "trace-1")
|
||||
|
||||
assert created["type"] == "session.created"
|
||||
assert created["session"]["id"] == "trace-1"
|
||||
assert created["session"]["audio"]["input"]["format"] == {"type": "audio/pcm", "rate": 24000}
|
||||
assert created["session"]["audio"]["input"]["transcription"] == {"model": MUSE_MODEL}
|
||||
|
||||
|
||||
def test_audio_before_session_update_is_rejected():
|
||||
config = MetaRealtimeConfig()
|
||||
config.validate_environment({}, MUSE_MODEL, api_key="secret-token")
|
||||
|
||||
with pytest.raises(MuseProtocolError, match=r"session\.update must configure"):
|
||||
config.transform_realtime_request(_event("input_audio_buffer.append", audio="AAAA"), MUSE_MODEL)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("rate", "packet_bytes"), [(16_000, 2_560), (24_000, 3_840)])
|
||||
def test_pcm_is_packetized_into_raw_binary_frames(rate: int, packet_bytes: int):
|
||||
config = _configured(rate=rate)
|
||||
pcm = b"\xff\xfe\x00\x80" * (packet_bytes // 2) + b"\x01\x02\x03\x04"
|
||||
|
||||
frames = config.transform_realtime_request(
|
||||
_event("input_audio_buffer.append", audio=base64.b64encode(pcm).decode()), MUSE_MODEL
|
||||
)
|
||||
remainder = config.transform_realtime_request(_event("input_audio_buffer.commit"), MUSE_MODEL)
|
||||
|
||||
assert frames == (pcm[:packet_bytes], pcm[packet_bytes : packet_bytes * 2])
|
||||
assert remainder == (pcm[packet_bytes * 2 :],)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("audio", "message"),
|
||||
[
|
||||
("not base64!", "valid base64"),
|
||||
(base64.b64encode(b"\x00").decode(), "complete samples"),
|
||||
(12, "base64 string"),
|
||||
("A" * (4 * ((24_000 * 2 * 4 + 2) // 3) + 4), "four-second backlog"),
|
||||
],
|
||||
)
|
||||
def test_invalid_audio_appends_are_rejected(audio: object, message: str):
|
||||
config = _configured()
|
||||
|
||||
with pytest.raises(MuseProtocolError, match=message):
|
||||
config.transform_realtime_request(_event("input_audio_buffer.append", audio=audio), MUSE_MODEL)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_sends_are_paced_to_real_time():
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def record_sleep(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
config = _configured(monotonic=lambda: 10.0, sleep=record_sleep)
|
||||
packet = b"\x01\x02" * 1_920
|
||||
|
||||
await config.pace_backend_send(packet)
|
||||
await config.pace_backend_send(packet)
|
||||
await config.pace_backend_send(packet)
|
||||
|
||||
assert sleeps == pytest.approx([0.08, 0.16])
|
||||
|
||||
|
||||
def test_endpointing_commit_flushes_without_end_stream_but_end_sends_it_once():
|
||||
config = _configured(turn_detection="server_vad")
|
||||
|
||||
assert config.transform_realtime_request(_event("input_audio_buffer.commit"), MUSE_MODEL) == ()
|
||||
assert config.transform_realtime_request(_event("input_audio_buffer.end"), MUSE_MODEL) == ('{"type":"endStream"}',)
|
||||
assert config.transform_realtime_request(_event("input_audio_buffer.end"), MUSE_MODEL) == ()
|
||||
|
||||
|
||||
def test_push_to_talk_commit_ends_the_stream_once():
|
||||
config = _configured(turn_detection=None)
|
||||
config.transform_realtime_request(
|
||||
_event("input_audio_buffer.append", audio=base64.b64encode(b"\x01\x02").decode()), MUSE_MODEL
|
||||
)
|
||||
|
||||
assert config.transform_realtime_request(_event("input_audio_buffer.commit"), MUSE_MODEL) == (
|
||||
b"\x01\x02",
|
||||
'{"type":"endStream"}',
|
||||
)
|
||||
assert config.transform_realtime_request(_event("input_audio_buffer.end"), MUSE_MODEL) == ()
|
||||
|
||||
|
||||
def test_clear_drops_buffered_remainder_and_unknown_events_are_ignored():
|
||||
config = _configured()
|
||||
config.transform_realtime_request(
|
||||
_event("input_audio_buffer.append", audio=base64.b64encode(b"\x01\x02").decode()), MUSE_MODEL
|
||||
)
|
||||
|
||||
assert config.transform_realtime_request(_event("input_audio_buffer.clear"), MUSE_MODEL) == ()
|
||||
assert config.transform_realtime_request(_event("response.create"), MUSE_MODEL) == ()
|
||||
assert config.transform_realtime_request(_event("input_audio_buffer.commit"), MUSE_MODEL) == ()
|
||||
|
||||
|
||||
def test_provider_ack_becomes_session_created_with_provider_id():
|
||||
config = _configured(rate=16_000, turn_detection=None)
|
||||
|
||||
(created,) = _backend_events(config, json.dumps({"sessionId": " provider-session "}))
|
||||
|
||||
assert created["type"] == "session.created"
|
||||
assert created["session"]["id"] == "provider-session"
|
||||
assert created["session"]["audio"]["input"]["format"]["rate"] == 16000
|
||||
assert created["session"]["audio"]["input"]["turn_detection"] is None
|
||||
|
||||
|
||||
def test_provider_turn_events_and_close_usage_flow_through_config():
|
||||
config = _configured()
|
||||
|
||||
assert _backend_events(config, json.dumps({"type": "audioProgress", "audioProcessedMs": 1349})) == []
|
||||
assert _backend_events(config, _event("speechStart", turnId="t1"))[0]["type"] == "input_audio_buffer.speech_started"
|
||||
assert _backend_events(config, _event("speechEnd", turnId="t1"))[0]["type"] == "input_audio_buffer.speech_stopped"
|
||||
completed = _backend_events(config, _event("speechComplete", turnId="t1", transcript="what is the weather"))
|
||||
|
||||
assert [event["type"] for event in completed] == ["conversation.item.input_audio_transcription.completed"]
|
||||
assert completed[0]["usage"] == {"type": "duration", "seconds": 1.349}
|
||||
assert config.unbilled_usage_on_session_close(MUSE_MODEL) is None
|
||||
|
||||
assert _backend_events(config, json.dumps({"type": "audioProgress", "audioProcessedMs": 2349})) == []
|
||||
assert config.unbilled_usage_on_session_close(MUSE_MODEL) == {"type": "duration", "seconds": 1.0}
|
||||
|
||||
|
||||
def test_provider_error_frame_becomes_openai_error_without_leaking_token():
|
||||
config = _configured()
|
||||
|
||||
(error,) = _backend_events(config, _event("error", message="bad token secret-token"))
|
||||
|
||||
assert error == {
|
||||
"type": "error",
|
||||
"error": {"type": "server_error", "message": "Meta Muse realtime transcription failed"},
|
||||
}
|
||||
assert "secret-token" not in json.dumps(error)
|
||||
|
||||
|
||||
def test_invalid_provider_ack_is_rejected():
|
||||
config = _configured()
|
||||
|
||||
with pytest.raises(MuseProtocolError, match="invalid handshake response"):
|
||||
_backend_events(config, json.dumps({"sessionId": ""}))
|
||||
|
|
@ -11,7 +11,8 @@ import pytest
|
|||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.llms.base_llm.ocr.transformation import OCRResponse
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging, use_custom_pricing_for_model
|
||||
from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse, OCRUsageInfo
|
||||
from litellm.llms.custom_httpx import llm_http_handler
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.ocr.legacy import _prepare_ocr_request
|
||||
|
|
@ -198,3 +199,61 @@ def test_generic_azure_connection_still_applies_to_foundry_ocr(monkeypatch: pyte
|
|||
|
||||
assert prepared.api_key == "generic-key"
|
||||
assert prepared.api_base == "https://generic.example.com"
|
||||
|
||||
|
||||
PRICING_OCR_MODEL: Final = "mistral/some-unmapped-ocr-model-for-testing"
|
||||
PRICING_DOCUMENT: Final = {"type": "document_url", "document_url": "https://example.com/doc.pdf"}
|
||||
|
||||
|
||||
def _pricing_logging_obj() -> Logging:
|
||||
return Logging(
|
||||
model=PRICING_OCR_MODEL,
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="ocr",
|
||||
start_time=None,
|
||||
litellm_call_id="test-ocr-request-pricing",
|
||||
function_id="1234",
|
||||
)
|
||||
|
||||
|
||||
def _prepare_with_pricing(kwargs: dict[str, object]) -> Logging:
|
||||
logging_obj: Final = _pricing_logging_obj()
|
||||
_prepare_ocr_request(
|
||||
model=PRICING_OCR_MODEL,
|
||||
document=dict(PRICING_DOCUMENT),
|
||||
api_key="test-key",
|
||||
api_base=None,
|
||||
timeout=None,
|
||||
custom_llm_provider=None,
|
||||
extra_headers=None,
|
||||
kwargs={"litellm_logging_obj": logging_obj, **kwargs},
|
||||
)
|
||||
return logging_obj
|
||||
|
||||
|
||||
def test_prepare_ocr_request_forwards_custom_pricing_to_logging_params() -> None:
|
||||
logging_obj: Final = _prepare_with_pricing({"ocr_cost_per_page": 0.05, "ocr_cost_per_credit": 0.5})
|
||||
|
||||
assert logging_obj.litellm_params["ocr_cost_per_page"] == 0.05
|
||||
assert logging_obj.litellm_params["ocr_cost_per_credit"] == 0.5
|
||||
assert use_custom_pricing_for_model(logging_obj.litellm_params) is True
|
||||
|
||||
|
||||
def test_prepare_ocr_request_without_custom_pricing_leaves_logging_params_unpriced() -> None:
|
||||
logging_obj: Final = _prepare_with_pricing({})
|
||||
|
||||
assert "ocr_cost_per_page" not in logging_obj.litellm_params
|
||||
assert use_custom_pricing_for_model(logging_obj.litellm_params) is False
|
||||
|
||||
|
||||
def test_direct_ocr_call_bills_request_level_per_page_pricing() -> None:
|
||||
assert PRICING_OCR_MODEL not in litellm.model_cost
|
||||
logging_obj: Final = _prepare_with_pricing({"ocr_cost_per_page": 0.05})
|
||||
response: Final = OCRResponse(
|
||||
pages=[OCRPage(index=index, markdown=f"page {index}") for index in range(3)],
|
||||
model=PRICING_OCR_MODEL,
|
||||
usage_info=OCRUsageInfo(pages_processed=3),
|
||||
)
|
||||
|
||||
assert logging_obj._response_cost_calculator(result=response) == pytest.approx(0.05 * 3)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from litellm.proxy.common_utils.load_config_utils import get_file_contents_from_s3
|
||||
from litellm.proxy.common_utils.load_config_utils import (
|
||||
gcs_config_bucket,
|
||||
get_config_from_bucket,
|
||||
get_file_contents_from_s3,
|
||||
resolve_bucket_includes,
|
||||
)
|
||||
|
||||
|
||||
class TestGetFileContentsFromS3:
|
||||
|
|
@ -83,3 +92,385 @@ class TestGetFileContentsFromS3:
|
|||
|
||||
# Verify yaml.safe_load was called with the decoded content
|
||||
mock_yaml_load.assert_called_once_with(yaml_content)
|
||||
|
||||
|
||||
class TestBucketConfigIncludes:
|
||||
|
||||
@staticmethod
|
||||
def _bucket(objects):
|
||||
async def fetch(object_key):
|
||||
return objects.get(object_key)
|
||||
|
||||
return fetch
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_include_resolves_against_the_config_objects_prefix(self):
|
||||
merged = await resolve_bucket_includes(
|
||||
config={"include": ["model_config.yaml"], "general_settings": {"master_key": "sk-1234"}},
|
||||
object_key="configs/prod/config.yaml",
|
||||
fetch=self._bucket(
|
||||
{"configs/prod/model_config.yaml": {"model_list": [{"model_name": "gpt-4o-mini"}]}}
|
||||
),
|
||||
)
|
||||
|
||||
assert merged == {
|
||||
"general_settings": {"master_key": "sk-1234"},
|
||||
"model_list": [{"model_name": "gpt-4o-mini"}],
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_include_with_a_leading_slash_reads_from_the_bucket_root(self):
|
||||
merged = await resolve_bucket_includes(
|
||||
config={"include": ["/shared/models.yaml"]},
|
||||
object_key="configs/prod/config.yaml",
|
||||
fetch=self._bucket({"shared/models.yaml": {"model_list": [{"model_name": "shared"}]}}),
|
||||
)
|
||||
|
||||
assert merged == {"model_list": [{"model_name": "shared"}]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_include_walks_out_of_the_prefix_with_dot_dot(self):
|
||||
merged = await resolve_bucket_includes(
|
||||
config={"include": ["../shared/models.yaml"]},
|
||||
object_key="configs/prod/config.yaml",
|
||||
fetch=self._bucket({"configs/shared/models.yaml": {"model_list": [{"model_name": "shared"}]}}),
|
||||
)
|
||||
|
||||
assert merged == {"model_list": [{"model_name": "shared"}]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_included_configs_may_declare_further_includes(self):
|
||||
merged = await resolve_bucket_includes(
|
||||
config={"include": ["models.yaml"]},
|
||||
object_key="configs/config.yaml",
|
||||
fetch=self._bucket(
|
||||
{
|
||||
"configs/models.yaml": {
|
||||
"include": ["extra/more_models.yaml"],
|
||||
"model_list": [{"model_name": "first"}],
|
||||
},
|
||||
"configs/extra/more_models.yaml": {"model_list": [{"model_name": "second"}]},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
assert merged == {"model_list": [{"model_name": "first"}, {"model_name": "second"}]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_nested_include_resolves_against_the_object_that_declares_it(self):
|
||||
merged = await resolve_bucket_includes(
|
||||
config={"include": ["shared/models.yaml"]},
|
||||
object_key="configs/config.yaml",
|
||||
fetch=self._bucket(
|
||||
{
|
||||
"configs/shared/models.yaml": {
|
||||
"include": ["more_models.yaml"],
|
||||
"model_list": [{"model_name": "first"}],
|
||||
},
|
||||
"configs/shared/more_models.yaml": {"model_list": [{"model_name": "second"}]},
|
||||
"configs/more_models.yaml": {"model_list": [{"model_name": "wrong-prefix"}]},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
assert merged == {"model_list": [{"model_name": "first"}, {"model_name": "second"}]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_object_pulled_in_twice_is_merged_once(self):
|
||||
merged = await resolve_bucket_includes(
|
||||
config={"include": ["a.yaml", "b.yaml"]},
|
||||
object_key="configs/config.yaml",
|
||||
fetch=self._bucket(
|
||||
{
|
||||
"configs/a.yaml": {"include": ["shared.yaml"]},
|
||||
"configs/b.yaml": {"include": ["./shared.yaml"]},
|
||||
"configs/shared.yaml": {"model_list": [{"model_name": "shared"}]},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
assert merged == {"model_list": [{"model_name": "shared"}]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_cycle_between_included_objects_terminates(self):
|
||||
merged = await asyncio.wait_for(
|
||||
resolve_bucket_includes(
|
||||
config={"include": ["a.yaml"]},
|
||||
object_key="configs/config.yaml",
|
||||
fetch=self._bucket(
|
||||
{
|
||||
"configs/a.yaml": {"include": ["b.yaml"], "model_list": [{"model_name": "from-a"}]},
|
||||
"configs/b.yaml": {"include": ["a.yaml"], "model_list": [{"model_name": "from-b"}]},
|
||||
}
|
||||
),
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert merged == {"model_list": [{"model_name": "from-a"}, {"model_name": "from-b"}]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_values_are_extended_and_other_values_are_overridden(self):
|
||||
merged = await resolve_bucket_includes(
|
||||
config={
|
||||
"include": ["models.yaml"],
|
||||
"model_list": [{"model_name": "from-root"}],
|
||||
"litellm_settings": {"drop_params": True},
|
||||
},
|
||||
object_key="config.yaml",
|
||||
fetch=self._bucket(
|
||||
{
|
||||
"models.yaml": {
|
||||
"model_list": [{"model_name": "from-include"}],
|
||||
"litellm_settings": {"num_retries": 3},
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
assert merged == {
|
||||
"model_list": [{"model_name": "from-root"}, {"model_name": "from-include"}],
|
||||
"litellm_settings": {"num_retries": 3},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_missing_included_object_fails_loudly_with_its_key(self):
|
||||
with pytest.raises(FileNotFoundError, match=re.escape("configs/prod/model_config.yaml")):
|
||||
await resolve_bucket_includes(
|
||||
config={"include": ["model_config.yaml"]},
|
||||
object_key="configs/prod/config.yaml",
|
||||
fetch=self._bucket({}),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_list_include_fails_loudly(self):
|
||||
with pytest.raises(ValueError, match="'include' must be a list of file paths"):
|
||||
await resolve_bucket_includes(
|
||||
config={"include": "model_config.yaml"},
|
||||
object_key="config.yaml",
|
||||
fetch=self._bucket({}),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_config_from_bucket_merges_includes_over_s3(self, monkeypatch):
|
||||
objects = {
|
||||
"lit6982/config.yaml": {
|
||||
"include": ["model_config.yaml"],
|
||||
"general_settings": {"master_key": "sk-1234"},
|
||||
},
|
||||
"lit6982/model_config.yaml": {"model_list": [{"model_name": "included-model"}]},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.common_utils.load_config_utils.s3_object_reader",
|
||||
lambda bucket_name: objects.get,
|
||||
)
|
||||
|
||||
config = await get_config_from_bucket(
|
||||
bucket_type="s3", bucket_name="litellm-configs", object_key="lit6982/config.yaml"
|
||||
)
|
||||
|
||||
assert config == {
|
||||
"general_settings": {"master_key": "sk-1234"},
|
||||
"model_list": [{"model_name": "included-model"}],
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_blocking_s3_work_runs_off_the_event_loop_thread(self, monkeypatch):
|
||||
loop_thread = threading.current_thread()
|
||||
threads = []
|
||||
|
||||
def build_reader(bucket_name):
|
||||
threads.append(threading.current_thread())
|
||||
|
||||
def read(object_key):
|
||||
threads.append(threading.current_thread())
|
||||
return {"model_list": [{"model_name": "a-model"}]}
|
||||
|
||||
return read
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.common_utils.load_config_utils.s3_object_reader", build_reader)
|
||||
|
||||
await get_config_from_bucket(bucket_type="s3", bucket_name="litellm-configs", object_key="config.yaml")
|
||||
|
||||
assert len(threads) == 2 and loop_thread not in threads
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_s3_client_serves_the_whole_include_tree(self, monkeypatch):
|
||||
objects = {
|
||||
"lit6982/config.yaml": {"include": ["model_config.yaml"]},
|
||||
"lit6982/model_config.yaml": {"model_list": [{"model_name": "included-model"}]},
|
||||
}
|
||||
readers = []
|
||||
|
||||
def build_reader(bucket_name):
|
||||
requested = []
|
||||
readers.append(requested)
|
||||
|
||||
def read(object_key):
|
||||
requested.append(object_key)
|
||||
return objects.get(object_key)
|
||||
|
||||
return read
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.common_utils.load_config_utils.s3_object_reader", build_reader)
|
||||
|
||||
await get_config_from_bucket(
|
||||
bucket_type="s3", bucket_name="litellm-configs", object_key="lit6982/config.yaml"
|
||||
)
|
||||
|
||||
assert readers == [["lit6982/config.yaml", "lit6982/model_config.yaml"]]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_empty_included_object_merges_as_an_empty_config(self, monkeypatch):
|
||||
objects = {
|
||||
"lit6982/config.yaml": "include:\n - empty.yaml\nmodel_list:\n - model_name: only-model\n",
|
||||
"lit6982/empty.yaml": "",
|
||||
}
|
||||
|
||||
class FakeGCSBucket:
|
||||
async def download_gcs_object(self, object_key):
|
||||
return objects[object_key].encode("utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.common_utils.load_config_utils.gcs_config_bucket",
|
||||
lambda bucket_name: FakeGCSBucket(),
|
||||
)
|
||||
|
||||
config = await get_config_from_bucket(
|
||||
bucket_type="gcs", bucket_name="litellm-configs", object_key="lit6982/config.yaml"
|
||||
)
|
||||
|
||||
assert config == {"model_list": [{"model_name": "only-model"}]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_config_from_bucket_merges_includes_over_gcs(self, monkeypatch):
|
||||
objects = {
|
||||
"lit6982/config.yaml": {
|
||||
"include": ["model_config.yaml"],
|
||||
"general_settings": {"master_key": "sk-1234"},
|
||||
},
|
||||
"lit6982/model_config.yaml": {"model_list": [{"model_name": "included-model"}]},
|
||||
}
|
||||
|
||||
buckets = []
|
||||
|
||||
class FakeGCSBucket:
|
||||
def __init__(self):
|
||||
self.requested = []
|
||||
buckets.append(self)
|
||||
|
||||
async def download_gcs_object(self, object_key):
|
||||
self.requested.append(object_key)
|
||||
return yaml.dump(objects[object_key]).encode("utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.common_utils.load_config_utils.gcs_config_bucket",
|
||||
lambda bucket_name: FakeGCSBucket(),
|
||||
)
|
||||
|
||||
config = await get_config_from_bucket(
|
||||
bucket_type="gcs", bucket_name="litellm-configs", object_key="lit6982/config.yaml"
|
||||
)
|
||||
|
||||
assert config == {
|
||||
"general_settings": {"master_key": "sk-1234"},
|
||||
"model_list": [{"model_name": "included-model"}],
|
||||
}
|
||||
assert [bucket.requested for bucket in buckets] == [
|
||||
["lit6982/config.yaml", "lit6982/model_config.yaml"]
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_config_from_bucket_returns_none_when_the_root_object_is_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.common_utils.load_config_utils.s3_object_reader",
|
||||
lambda bucket_name: (lambda object_key: None),
|
||||
)
|
||||
|
||||
assert (
|
||||
await get_config_from_bucket(
|
||||
bucket_type="s3", bucket_name="litellm-configs", object_key="missing.yaml"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_object_pulled_in_twice_is_read_once(self):
|
||||
objects = {
|
||||
"configs/a.yaml": {"include": ["shared.yaml"]},
|
||||
"configs/b.yaml": {"include": ["./shared.yaml"]},
|
||||
"configs/shared.yaml": {"model_list": [{"model_name": "shared"}]},
|
||||
}
|
||||
requested = []
|
||||
|
||||
async def fetch(object_key):
|
||||
requested.append(object_key)
|
||||
return objects.get(object_key)
|
||||
|
||||
await resolve_bucket_includes(
|
||||
config={"include": ["a.yaml", "b.yaml"]},
|
||||
object_key="configs/config.yaml",
|
||||
fetch=fetch,
|
||||
)
|
||||
|
||||
assert requested == ["configs/a.yaml", "configs/b.yaml", "configs/shared.yaml"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_empty_root_object_does_not_boot_an_empty_proxy(self, monkeypatch):
|
||||
class FakeGCSBucket:
|
||||
async def download_gcs_object(self, object_key):
|
||||
return b""
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.common_utils.load_config_utils.gcs_config_bucket",
|
||||
lambda bucket_name: FakeGCSBucket(),
|
||||
)
|
||||
|
||||
config = await get_config_from_bucket(
|
||||
bucket_type="gcs", bucket_name="litellm-configs", object_key="lit6982/config.yaml"
|
||||
)
|
||||
|
||||
assert config is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_object_that_is_not_valid_yaml_is_reported_as_a_yaml_error(self, monkeypatch, caplog):
|
||||
class FakeGCSBucket:
|
||||
async def download_gcs_object(self, object_key):
|
||||
return b"model_list: [\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.common_utils.load_config_utils.gcs_config_bucket",
|
||||
lambda bucket_name: FakeGCSBucket(),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"):
|
||||
config = await get_config_from_bucket(
|
||||
bucket_type="gcs", bucket_name="litellm-configs", object_key="lit6982/config.yaml"
|
||||
)
|
||||
|
||||
assert config is None
|
||||
assert [
|
||||
record
|
||||
for record in caplog.records
|
||||
if "not valid YAML" in record.getMessage() and "lit6982/config.yaml" in record.getMessage()
|
||||
]
|
||||
|
||||
|
||||
class TestGCSConfigBucketClient:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reading_a_config_from_gcs_does_not_need_an_enterprise_license(self, monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False)
|
||||
|
||||
bucket = gcs_config_bucket("litellm-configs")
|
||||
|
||||
assert bucket is not None
|
||||
assert bucket.BUCKET_NAME == "litellm-configs"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reading_a_config_from_gcs_starts_no_background_task(self, monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
|
||||
running_before = asyncio.all_tasks()
|
||||
|
||||
gcs_config_bucket("litellm-configs")
|
||||
|
||||
assert asyncio.all_tasks() - running_before == set()
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ def _stagger(scheduler: AsyncIOScheduler, identity: str = "pod-a:1", **overrides
|
|||
return apply_scheduled_job_stagger(scheduler=scheduler, settings=_settings(**overrides), identity=identity)
|
||||
|
||||
|
||||
def _trigger_of(scheduler: AsyncIOScheduler, job_id: str):
|
||||
return next(job.trigger for job in scheduler.get_jobs() if job.id == job_id)
|
||||
|
||||
|
||||
def _fire_times(trigger, start: datetime, steps: int) -> tuple[datetime, ...]:
|
||||
"""The fire times APScheduler would produce, each computed from the one before it"""
|
||||
return tuple(
|
||||
|
|
@ -133,22 +137,24 @@ def test_default_cron_is_staggered_and_keeps_its_offset_on_every_later_fire():
|
|||
applied = _stagger(scheduler)
|
||||
assert applied[PTU_ROLLUP_JOB_ID] > 0
|
||||
|
||||
trigger = next(job.trigger for job in scheduler.get_jobs() if job.id == PTU_ROLLUP_JOB_ID)
|
||||
fires = _fire_times(trigger, datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), 3)
|
||||
start = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
|
||||
fires = _fire_times(_trigger_of(scheduler, PTU_ROLLUP_JOB_ID), start, 3)
|
||||
|
||||
expected = timedelta(minutes=15) + timedelta(seconds=applied[PTU_ROLLUP_JOB_ID])
|
||||
assert [fire - fire.replace(hour=0, minute=0, second=0, microsecond=0) for fire in fires] == [expected] * 3
|
||||
|
||||
|
||||
async def test_explicit_offset_overrides_the_derived_one_and_zero_pins_a_job():
|
||||
def test_explicit_offset_overrides_the_derived_one_and_zero_pins_a_job():
|
||||
scheduler = _with_jobs(_scheduler())
|
||||
applied = _stagger(scheduler, offsets={"periodic_reload_job": 0, PTU_ROLLUP_JOB_ID: 7})
|
||||
unstaggered = _next_run_times(_with_jobs(_scheduler()))
|
||||
staggered = _next_run_times(scheduler)
|
||||
|
||||
assert applied["periodic_reload_job"] == 0
|
||||
assert applied[PTU_ROLLUP_JOB_ID] == 7
|
||||
assert staggered[PTU_ROLLUP_JOB_ID] - unstaggered[PTU_ROLLUP_JOB_ID] == timedelta(seconds=7)
|
||||
|
||||
start = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
|
||||
staggered = _trigger_of(scheduler, PTU_ROLLUP_JOB_ID)
|
||||
unstaggered = _trigger_of(_with_jobs(_scheduler()), PTU_ROLLUP_JOB_ID)
|
||||
assert _fire_times(staggered, start, 1)[0] - _fire_times(unstaggered, start, 1)[0] == timedelta(seconds=7)
|
||||
|
||||
|
||||
async def test_disabling_the_stagger_leaves_every_schedule_untouched():
|
||||
|
|
|
|||
|
|
@ -35,6 +35,14 @@ DB_ENV_KEYS = (
|
|||
_db_env_snapshot_key = pytest.StashKey[dict[str, Optional[str]]]()
|
||||
|
||||
|
||||
def _is_zombie(pid: int) -> bool:
|
||||
try:
|
||||
stat: Final = Path(f"/proc/{pid}/stat").read_text()
|
||||
except OSError:
|
||||
return False
|
||||
return stat.rpartition(")")[2].split()[0] == "Z"
|
||||
|
||||
|
||||
def _db_env_snapshot() -> dict[str, Optional[str]]:
|
||||
return {key: os.environ.get(key) for key in DB_ENV_KEYS}
|
||||
|
||||
|
|
@ -136,6 +144,8 @@ class FakePrismaCli:
|
|||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return True
|
||||
if _is_zombie(pid):
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import hashlib
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import (
|
||||
MAX_ARCHIVE_ENTRIES,
|
||||
build_skill_archive,
|
||||
)
|
||||
|
||||
MANIFEST = b"""---
|
||||
name: pdf-summarizer
|
||||
description: Summarize a PDF into an executive brief.
|
||||
---
|
||||
|
||||
Read the PDF, then write the brief.
|
||||
"""
|
||||
|
||||
|
||||
def zip_bytes(files: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, content in files.items():
|
||||
archive.writestr(name, content)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def entries_of(content: bytes) -> dict[str, bytes]:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
||||
return {name: archive.read(name) for name in archive.namelist()}
|
||||
|
||||
|
||||
def test_single_top_level_folder_is_stripped_so_skill_md_sits_at_the_root():
|
||||
archive = build_skill_archive(
|
||||
zip_bytes(
|
||||
{
|
||||
"pdf-summarizer/SKILL.md": MANIFEST,
|
||||
"pdf-summarizer/reference.md": b"page citations",
|
||||
"pdf-summarizer/scripts/extract.py": b"print('hi')",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert archive is not None
|
||||
assert entries_of(archive.content) == {
|
||||
"SKILL.md": MANIFEST,
|
||||
"reference.md": b"page citations",
|
||||
"scripts/extract.py": b"print('hi')",
|
||||
}
|
||||
|
||||
|
||||
def test_digest_covers_the_repacked_bytes_and_is_stable_across_builds():
|
||||
upload = zip_bytes({"pdf-summarizer/SKILL.md": MANIFEST, "pdf-summarizer/reference.md": b"page citations"})
|
||||
|
||||
first = build_skill_archive(upload)
|
||||
second = build_skill_archive(upload)
|
||||
|
||||
assert first is not None and second is not None
|
||||
assert first.digest == f"sha256:{hashlib.sha256(first.content).hexdigest()}"
|
||||
assert first.content == second.content
|
||||
|
||||
|
||||
def test_an_upload_that_is_already_flat_keeps_every_file_where_it_is():
|
||||
archive = build_skill_archive(zip_bytes({"SKILL.md": MANIFEST, "reference.md": b"page citations"}))
|
||||
|
||||
assert archive is not None
|
||||
assert sorted(entries_of(archive.content)) == ["SKILL.md", "reference.md"]
|
||||
|
||||
|
||||
def test_manifest_frontmatter_supplies_the_declared_name_and_description():
|
||||
archive = build_skill_archive(zip_bytes({"pdf-summarizer/SKILL.md": MANIFEST}))
|
||||
|
||||
assert archive is not None
|
||||
assert archive.declared_name == "pdf-summarizer"
|
||||
assert archive.declared_description == "Summarize a PDF into an executive brief."
|
||||
|
||||
|
||||
def test_a_manifest_without_frontmatter_declares_nothing():
|
||||
archive = build_skill_archive(zip_bytes({"pdf-summarizer/SKILL.md": b"just prose, no frontmatter"}))
|
||||
|
||||
assert archive is not None
|
||||
assert archive.declared_name is None
|
||||
assert archive.declared_description is None
|
||||
|
||||
|
||||
def test_a_manifest_buried_below_the_stripped_folder_is_not_installable():
|
||||
assert build_skill_archive(zip_bytes({"pdf-summarizer/nested/SKILL.md": MANIFEST})) is None
|
||||
|
||||
|
||||
def test_an_upload_with_no_manifest_is_not_installable():
|
||||
assert build_skill_archive(zip_bytes({"pdf-summarizer/reference.md": b"page citations"})) is None
|
||||
|
||||
|
||||
def test_a_non_zip_upload_is_not_installable():
|
||||
assert build_skill_archive(MANIFEST) is None
|
||||
|
||||
|
||||
def test_a_path_traversal_entry_is_not_installable():
|
||||
assert build_skill_archive(zip_bytes({"SKILL.md": MANIFEST, "../escape.md": b"nope"})) is None
|
||||
|
||||
|
||||
def test_an_upload_over_the_entry_cap_is_not_installable():
|
||||
files = {"pdf-summarizer/SKILL.md": MANIFEST} | {
|
||||
f"pdf-summarizer/file-{index}.md": b"x" for index in range(MAX_ARCHIVE_ENTRIES)
|
||||
}
|
||||
|
||||
assert build_skill_archive(zip_bytes(files)) is None
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import hashlib
|
||||
import io
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import litellm
|
||||
from litellm.models.skills import LiteLLM_SkillsTable
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_endpoints import (
|
||||
router,
|
||||
stored_skill,
|
||||
stored_skills,
|
||||
)
|
||||
from litellm.types.proxy.discovery_endpoints.agent_skills_endpoints import (
|
||||
AGENT_SKILLS_DISCOVERY_SCHEMA_URL,
|
||||
)
|
||||
|
||||
WELL_KNOWN_PATHS = ("/.well-known/agent-skills/index.json", "/.well-known/skills/index.json")
|
||||
|
||||
MANIFEST = b"""---
|
||||
name: pdf-summarizer
|
||||
description: Summarize a PDF into an executive brief.
|
||||
---
|
||||
|
||||
Read the PDF, then write the brief.
|
||||
"""
|
||||
|
||||
|
||||
def zip_bytes(files: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, content in files.items():
|
||||
archive.writestr(name, content)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def skill(
|
||||
skill_id: str,
|
||||
*,
|
||||
display_title: str | None = "PDF Summarizer",
|
||||
description: str | None = None,
|
||||
files: dict[str, bytes] | None = None,
|
||||
updated_at: datetime | None = None,
|
||||
) -> LiteLLM_SkillsTable:
|
||||
return LiteLLM_SkillsTable(
|
||||
skill_id=skill_id,
|
||||
display_title=display_title,
|
||||
description=description,
|
||||
file_content=zip_bytes(files if files is not None else {"pdf-summarizer/SKILL.md": MANIFEST}),
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
def client_for(*skills: LiteLLM_SkillsTable) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
def _skills() -> tuple[LiteLLM_SkillsTable, ...]:
|
||||
return skills
|
||||
|
||||
def _skill(skill_id: str) -> LiteLLM_SkillsTable | None:
|
||||
return next((candidate for candidate in skills if candidate.skill_id == skill_id), None)
|
||||
|
||||
app.dependency_overrides[stored_skills] = _skills
|
||||
app.dependency_overrides[stored_skill] = _skill
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index_enabled(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "public_skills_index", True)
|
||||
|
||||
|
||||
def test_discovery_is_absent_until_public_skills_index_is_enabled(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "public_skills_index", False)
|
||||
client = client_for(skill("litellm_skill_1"))
|
||||
|
||||
for path in WELL_KNOWN_PATHS:
|
||||
assert client.get(path).status_code == 404
|
||||
assert client.get("/v1/skills/litellm_skill_1/archive").status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", WELL_KNOWN_PATHS)
|
||||
def test_index_publishes_each_stored_skill_in_the_v0_2_0_shape(index_enabled, path):
|
||||
client = client_for(skill("litellm_skill_1"))
|
||||
|
||||
body = client.get(path).json()
|
||||
|
||||
assert body["$schema"] == AGENT_SKILLS_DISCOVERY_SCHEMA_URL
|
||||
assert len(body["skills"]) == 1
|
||||
entry = body["skills"][0]
|
||||
assert entry["name"] == "pdf-summarizer"
|
||||
assert entry["type"] == "archive"
|
||||
assert entry["description"] == "Summarize a PDF into an executive brief."
|
||||
assert entry["url"].endswith("/v1/skills/litellm_skill_1/archive")
|
||||
assert entry["digest"].startswith("sha256:")
|
||||
|
||||
|
||||
def test_index_digest_matches_the_bytes_the_archive_route_serves(index_enabled):
|
||||
client = client_for(skill("litellm_skill_1"))
|
||||
|
||||
entry = client.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]
|
||||
downloaded = client.get(entry["url"])
|
||||
|
||||
assert downloaded.status_code == 200
|
||||
assert downloaded.headers["content-type"] == "application/zip"
|
||||
assert entry["digest"] == f"sha256:{hashlib.sha256(downloaded.content).hexdigest()}"
|
||||
|
||||
|
||||
def test_install_name_falls_back_to_the_manifest_name_without_a_display_title(index_enabled):
|
||||
client = client_for(skill("litellm_skill_1", display_title=None))
|
||||
|
||||
assert client.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["name"] == "pdf-summarizer"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest, stored_description, expected",
|
||||
[
|
||||
(MANIFEST, "registry copy", "Summarize a PDF into an executive brief."),
|
||||
(b"no frontmatter here", "registry copy", "registry copy"),
|
||||
(b"no frontmatter here", None, "PDF Summarizer"),
|
||||
],
|
||||
)
|
||||
def test_description_prefers_the_manifest_then_the_registry_then_the_title(
|
||||
index_enabled, manifest, stored_description, expected
|
||||
):
|
||||
client = client_for(
|
||||
skill(
|
||||
"litellm_skill_1",
|
||||
description=stored_description,
|
||||
files={"pdf-summarizer/SKILL.md": manifest},
|
||||
)
|
||||
)
|
||||
|
||||
assert client.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["description"] == expected
|
||||
|
||||
|
||||
def test_skills_sharing_a_title_get_distinct_install_names(index_enabled):
|
||||
client = client_for(
|
||||
skill("litellm_skill_2", files={"pdf-summarizer/SKILL.md": b"second"}),
|
||||
skill("litellm_skill_1", files={"pdf-summarizer/SKILL.md": b"first"}),
|
||||
)
|
||||
|
||||
names = [entry["name"] for entry in client.get(WELL_KNOWN_PATHS[0]).json()["skills"]]
|
||||
|
||||
assert names == ["pdf-summarizer", "pdf-summarizer-2"]
|
||||
|
||||
|
||||
def test_uploads_without_a_root_manifest_are_left_out_of_the_index(index_enabled):
|
||||
client = client_for(
|
||||
skill("litellm_skill_1"),
|
||||
skill("litellm_skill_2", files={"pdf-summarizer/reference.md": b"no manifest"}),
|
||||
)
|
||||
|
||||
body = client.get(WELL_KNOWN_PATHS[0]).json()
|
||||
|
||||
assert [entry["url"].split("/")[-2] for entry in body["skills"]] == ["litellm_skill_1"]
|
||||
assert client.get("/v1/skills/litellm_skill_2/archive").status_code == 404
|
||||
|
||||
|
||||
def test_archive_route_404s_for_a_skill_that_does_not_exist(index_enabled):
|
||||
client = client_for(skill("litellm_skill_1"))
|
||||
|
||||
assert client.get("/v1/skills/litellm_skill_missing/archive").status_code == 404
|
||||
|
||||
|
||||
def test_a_stored_skill_is_repacked_once_per_version(index_enabled):
|
||||
stamp = datetime(2026, 9, 6, 9, 0, tzinfo=timezone.utc)
|
||||
first = client_for(skill("litellm_skill_cached", files={"s/SKILL.md": MANIFEST}, updated_at=stamp))
|
||||
published = first.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"]
|
||||
|
||||
unchanged_row = client_for(
|
||||
skill("litellm_skill_cached", files={"s/SKILL.md": MANIFEST, "s/extra.md": b"rewritten"}, updated_at=stamp)
|
||||
)
|
||||
|
||||
assert unchanged_row.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"] == published
|
||||
assert hashlib.sha256(unchanged_row.get("/v1/skills/litellm_skill_cached/archive").content).hexdigest() == (
|
||||
published.removeprefix("sha256:")
|
||||
)
|
||||
|
||||
|
||||
def test_a_skill_edited_since_the_last_read_is_republished(index_enabled):
|
||||
stamp = datetime(2026, 9, 6, 9, 0, tzinfo=timezone.utc)
|
||||
before = client_for(skill("litellm_skill_edited", files={"s/SKILL.md": MANIFEST}, updated_at=stamp))
|
||||
published = before.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"]
|
||||
|
||||
after = client_for(
|
||||
skill(
|
||||
"litellm_skill_edited",
|
||||
files={"s/SKILL.md": MANIFEST, "s/extra.md": b"rewritten"},
|
||||
updated_at=datetime(2026, 9, 6, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
)
|
||||
republished = after.get(WELL_KNOWN_PATHS[0]).json()["skills"][0]["digest"]
|
||||
|
||||
assert republished != published
|
||||
assert hashlib.sha256(after.get("/v1/skills/litellm_skill_edited/archive").content).hexdigest() == (
|
||||
republished.removeprefix("sha256:")
|
||||
)
|
||||
|
||||
|
||||
def test_openapi_declares_the_archive_route_as_a_zip_download(index_enabled):
|
||||
schema = client_for(skill("litellm_skill_1")).get("/openapi.json").json()
|
||||
|
||||
content = schema["paths"]["/v1/skills/{skill_id}/archive"]["get"]["responses"]["200"]["content"]
|
||||
|
||||
assert "application/zip" in content
|
||||
assert "application/json" not in content
|
||||
|
|
@ -15,6 +15,7 @@ Streaming: CSW.__anext__ stores args on logging_obj at stream end.
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -1422,7 +1423,7 @@ class TestArmDeferredStreamDispatch:
|
|||
async def test_native_stream_closure_enqueues_single_coroutine(self):
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
||||
logging_obj, _ = self._dispatch_recording_logging_obj()
|
||||
logging_obj, recorded = self._dispatch_recording_logging_obj()
|
||||
|
||||
async def _agen():
|
||||
yield b"x"
|
||||
|
|
@ -1433,20 +1434,89 @@ class TestArmDeferredStreamDispatch:
|
|||
user_api_key_dict=MagicMock(),
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
closure = logging_obj._on_deferred_stream_complete
|
||||
assert closure is not None
|
||||
assert logging_obj._on_deferred_stream_complete is not None
|
||||
|
||||
async def _logging_coroutine():
|
||||
return None
|
||||
|
||||
coro = _logging_coroutine()
|
||||
logging_obj._deferred_stream_complete_args = (coro,)
|
||||
with patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam
|
||||
GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue"
|
||||
) as mock_enqueue:
|
||||
await closure(coro)
|
||||
ProxyLogging._fire_deferred_stream_logging({"litellm_logging_obj": logging_obj})
|
||||
await asyncio.sleep(0)
|
||||
mock_enqueue.assert_called_once_with(async_coroutine=coro)
|
||||
assert recorded == {}
|
||||
coro.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("route_type", ["anthropic_messages", "aresponses"])
|
||||
async def test_raw_generator_stream_storing_csw_arg_shape_dispatches_success(self, route_type):
|
||||
"""Bridged /v1/messages returns AnthropicStreamWrapper's plain SSE
|
||||
generator, which shares its inner CustomStreamWrapper's logging_obj and
|
||||
so stores (assembled_response, cache_hit). The closure armed for a raw
|
||||
generator must accept that shape too, or _fire_deferred_stream_logging
|
||||
raises TypeError and the request loses its spend log and callbacks."""
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
||||
logging_obj, recorded = self._dispatch_recording_logging_obj()
|
||||
|
||||
async def _agen():
|
||||
yield b"x"
|
||||
|
||||
self._processor()._arm_deferred_stream_dispatch(
|
||||
response=_agen(),
|
||||
route_type=route_type,
|
||||
user_api_key_dict=MagicMock(),
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
assembled = object()
|
||||
logging_obj._deferred_stream_complete_args = (assembled, True)
|
||||
with patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam
|
||||
GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue"
|
||||
) as mock_enqueue:
|
||||
ProxyLogging._fire_deferred_stream_logging({"litellm_logging_obj": logging_obj})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_enqueue.assert_not_called()
|
||||
assert recorded["result"] is assembled
|
||||
assert recorded["cache_hit"] is True
|
||||
assert recorded["prefer_async_handlers"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("stored_args", [(object(),), (object(), object(), object())])
|
||||
async def test_raw_generator_stream_with_unknown_arg_shape_logs_and_drops(self, stored_args, caplog):
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
||||
logging_obj, recorded = self._dispatch_recording_logging_obj()
|
||||
|
||||
async def _agen():
|
||||
yield b"x"
|
||||
|
||||
self._processor()._arm_deferred_stream_dispatch(
|
||||
response=_agen(),
|
||||
route_type="anthropic_messages",
|
||||
user_api_key_dict=MagicMock(),
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
logging_obj._deferred_stream_complete_args = stored_args
|
||||
with (
|
||||
patch.object( # test-quality-ok: GLOBAL_LOGGING_WORKER is a process-global singleton with no injection seam
|
||||
GLOBAL_LOGGING_WORKER, "ensure_initialized_and_enqueue"
|
||||
) as mock_enqueue,
|
||||
caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"),
|
||||
):
|
||||
ProxyLogging._fire_deferred_stream_logging({"litellm_logging_obj": logging_obj})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_enqueue.assert_not_called()
|
||||
assert recorded == {}
|
||||
dropped = [r for r in caplog.records if r.getMessage().startswith("Deferred stream logging dropped")]
|
||||
assert len(dropped) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_csw_closure_routes_through_deferred_stream_guardrails(self, monkeypatch):
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
|
|
|
|||
|
|
@ -340,7 +340,8 @@ async def test_update_budget_recomputes_reset_at_when_duration_changes(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_budget_preserves_explicit_reset_at(client_and_mocks):
|
||||
@pytest.mark.parametrize("budget_duration", ["1d", None])
|
||||
async def test_update_budget_preserves_explicit_reset_at(client_and_mocks, budget_duration):
|
||||
"""An explicit budget_reset_at from the caller always wins over recompute."""
|
||||
client, _, mock_table = client_and_mocks
|
||||
captured = _capture_update_data(mock_table)
|
||||
|
|
@ -350,7 +351,7 @@ async def test_update_budget_preserves_explicit_reset_at(client_and_mocks):
|
|||
"/budget/update",
|
||||
json={
|
||||
"budget_id": "budget_explicit_reset",
|
||||
"budget_duration": "1d",
|
||||
"budget_duration": budget_duration,
|
||||
"budget_reset_at": explicit.isoformat(),
|
||||
},
|
||||
)
|
||||
|
|
@ -377,8 +378,7 @@ async def test_update_budget_without_duration_leaves_reset_at_untouched(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_budget_duration_none_does_not_recompute(client_and_mocks):
|
||||
"""Clearing budget_duration (explicit null) must not recompute against a None duration."""
|
||||
async def test_update_budget_duration_none_clears_obsolete_reset(client_and_mocks):
|
||||
client, _, mock_table = client_and_mocks
|
||||
captured = _capture_update_data(mock_table)
|
||||
|
||||
|
|
@ -389,7 +389,7 @@ async def test_update_budget_duration_none_does_not_recompute(client_and_mocks):
|
|||
assert resp.status_code == 200, resp.text
|
||||
|
||||
assert "budget_duration" in captured and captured["budget_duration"] is None
|
||||
assert "budget_reset_at" not in captured
|
||||
assert captured["budget_reset_at"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -613,6 +613,7 @@ def test_key_metadata_includes_recovered_user_email():
|
|||
"dirty-key": {
|
||||
"key_alias": "batch-worker",
|
||||
"team_id": "team-1",
|
||||
"user_id": "alice",
|
||||
"user_email": "alice@example.com",
|
||||
}
|
||||
},
|
||||
|
|
@ -620,6 +621,7 @@ def test_key_metadata_includes_recovered_user_email():
|
|||
)
|
||||
|
||||
assert meta.key_alias == "batch-worker"
|
||||
assert meta.user_id == "alice"
|
||||
assert meta.user_email == "alice@example.com"
|
||||
|
||||
|
||||
|
|
@ -848,9 +850,11 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
|
|||
mock_deleted_key.token = "deleted-key-hash"
|
||||
mock_deleted_key.key_alias = "toto-test-2"
|
||||
mock_deleted_key.team_id = "69cd4b77-b095-4489-8c46-4f2f31d840a2"
|
||||
mock_deleted_key.user_id = "deleted-key-owner"
|
||||
|
||||
mock_prisma.db.litellm_deletedverificationtoken = MagicMock()
|
||||
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[mock_deleted_key])
|
||||
mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[])
|
||||
|
||||
result = await get_daily_activity_aggregated(
|
||||
prisma_client=mock_prisma,
|
||||
|
|
@ -871,6 +875,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
|
|||
key_data = chat_endpoint.api_key_breakdown["deleted-key-hash"]
|
||||
assert key_data.metadata.key_alias == "toto-test-2"
|
||||
assert key_data.metadata.team_id == "69cd4b77-b095-4489-8c46-4f2f31d840a2"
|
||||
assert key_data.metadata.user_id == "deleted-key-owner"
|
||||
assert key_data.metrics.spend == 10.0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2097,6 +2097,22 @@ def test_update_internal_user_params_reset_max_budget_with_none():
|
|||
assert non_default_values["user_id"] == "test_user"
|
||||
|
||||
|
||||
def test_update_internal_user_params_explicit_duration_clear_overrides_role_default(monkeypatch):
|
||||
import litellm
|
||||
|
||||
monkeypatch.setattr(litellm, "internal_user_budget_duration", "30d")
|
||||
data = UpdateUserRequest(
|
||||
user_id="duration-clear-test",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
budget_duration=None,
|
||||
)
|
||||
|
||||
updated = _update_internal_user_params(data_json=data.model_dump(exclude_unset=True), data=data)
|
||||
|
||||
assert updated["budget_duration"] is None
|
||||
assert updated["budget_reset_at"] is None
|
||||
|
||||
|
||||
def test_update_internal_user_params_ignores_other_nones():
|
||||
"""
|
||||
Test that other fields are still filtered out if None
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import pytest
|
||||
|
||||
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
|
||||
AnthropicPassthroughLoggingHandler,
|
||||
|
|
@ -1551,6 +1552,7 @@ class TestInterruptedStreamOutputTokenRecovery:
|
|||
return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()
|
||||
|
||||
_MODEL = "claude-3-5-haiku-20241022"
|
||||
_PRICED_MODEL = "claude-sonnet-5"
|
||||
_OUTPUT_TEXT = (
|
||||
"The history of computing spans centuries, beginning with mechanical "
|
||||
"calculators and the abacus, advancing through Charles Babbage's "
|
||||
|
|
@ -1559,7 +1561,7 @@ class TestInterruptedStreamOutputTokenRecovery:
|
|||
"century that gave rise to the modern information age."
|
||||
)
|
||||
|
||||
def _interrupted_chunks(self, *, placeholder_output_tokens: int = 2):
|
||||
def _interrupted_chunks(self, *, placeholder_output_tokens: int = 2, model: str | None = None):
|
||||
from litellm.proxy.pass_through_endpoints.streaming_handler import (
|
||||
PassThroughStreamingHandler,
|
||||
)
|
||||
|
|
@ -1574,7 +1576,7 @@ class TestInterruptedStreamOutputTokenRecovery:
|
|||
"id": "msg_interrupted",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": self._MODEL,
|
||||
"model": model or self._MODEL,
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
|
|
@ -1676,6 +1678,81 @@ class TestInterruptedStreamOutputTokenRecovery:
|
|||
# provider count is preserved verbatim.
|
||||
assert usage.completion_tokens == final
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupted_stream_logs_cost_of_recovered_tokens(self):
|
||||
"""
|
||||
Regression (LIT-6872): stream_chunk_builder stamps usage.cost and
|
||||
_hidden_params["response_cost"] from the message_start placeholder before
|
||||
the interrupted stream is re-tokenized, and the success handler prefers
|
||||
that hidden cost over the recomputed one. The logged cost must price the
|
||||
recovered completion tokens, not the placeholder.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
class _SuccessRecorder(CustomLogger):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.success_kwargs: list = []
|
||||
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self.success_kwargs.append(kwargs)
|
||||
|
||||
recorder = _SuccessRecorder()
|
||||
logging_obj = LiteLLMLoggingObj(
|
||||
model=self._PRICED_MODEL,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="lit-6872",
|
||||
function_id="lit-6872",
|
||||
dynamic_async_success_callbacks=[recorder],
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
model=self._PRICED_MODEL,
|
||||
user="",
|
||||
optional_params={},
|
||||
litellm_params={"custom_llm_provider": "anthropic"},
|
||||
custom_llm_provider="anthropic",
|
||||
)
|
||||
placeholder = 1
|
||||
handled = AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks(
|
||||
litellm_logging_obj=logging_obj,
|
||||
passthrough_success_handler_obj=MagicMock(),
|
||||
url_route="/anthropic/v1/messages",
|
||||
request_body={"model": self._PRICED_MODEL, "stream": True},
|
||||
endpoint_type="messages",
|
||||
start_time=datetime.now(),
|
||||
all_chunks=self._interrupted_chunks(placeholder_output_tokens=placeholder, model=self._PRICED_MODEL),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
await logging_obj.dispatch_success_handlers(
|
||||
result=handled["result"],
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
cache_hit=False,
|
||||
prefer_async_handlers=True,
|
||||
**handled["kwargs"],
|
||||
)
|
||||
for _ in range(300):
|
||||
if recorder.success_kwargs:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert len(recorder.success_kwargs) == 1
|
||||
logged = recorder.success_kwargs[0]["standard_logging_object"]
|
||||
recovered_tokens = handled["result"].usage.completion_tokens
|
||||
assert recovered_tokens > placeholder
|
||||
assert logged["completion_tokens"] == recovered_tokens
|
||||
prompt_cost, completion_cost = litellm.cost_per_token(
|
||||
model=self._PRICED_MODEL, prompt_tokens=29, completion_tokens=recovered_tokens
|
||||
)
|
||||
_, placeholder_completion_cost = litellm.cost_per_token(
|
||||
model=self._PRICED_MODEL, prompt_tokens=29, completion_tokens=placeholder
|
||||
)
|
||||
assert logged["response_cost"] == pytest.approx(prompt_cost + completion_cost)
|
||||
assert logged["response_cost"] > prompt_cost + placeholder_completion_cost
|
||||
|
||||
|
||||
class TestStreamFalseDeduplication:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1832,8 +1832,10 @@ class TestOpenAIPassthroughResponsesStreamingSpendLog:
|
|||
def setup_method(self):
|
||||
self.start_time = datetime.now()
|
||||
self.end_time = datetime.now()
|
||||
|
||||
def _expected_spend(self) -> float:
|
||||
rates = litellm.model_cost[self.MODEL_MAP_KEY]
|
||||
self.expected_spend = (
|
||||
return (
|
||||
self.INPUT_TOKENS * rates["input_cost_per_token"]
|
||||
+ self.OUTPUT_TOKENS * rates["output_cost_per_token"]
|
||||
)
|
||||
|
|
@ -1914,7 +1916,7 @@ class TestOpenAIPassthroughResponsesStreamingSpendLog:
|
|||
logging_obj.model_call_details["custom_llm_provider"] = "openai"
|
||||
return logging_obj
|
||||
|
||||
def test_streamed_responses_passthrough_spend_log_is_priced(self):
|
||||
def test_streamed_responses_passthrough_spend_log_is_priced(self, local_model_cost_map):
|
||||
"""The spend row books the same tokens, spend and `resp_` id as the buffered call."""
|
||||
result = OpenAIPassthroughLoggingHandler._handle_logging_openai_collected_chunks(
|
||||
litellm_logging_obj=self._logging_obj(),
|
||||
|
|
@ -1942,7 +1944,7 @@ class TestOpenAIPassthroughResponsesStreamingSpendLog:
|
|||
assert spend_log_row["prompt_tokens"] == self.INPUT_TOKENS
|
||||
assert spend_log_row["completion_tokens"] == self.OUTPUT_TOKENS
|
||||
assert spend_log_row["total_tokens"] == self.INPUT_TOKENS + self.OUTPUT_TOKENS
|
||||
assert spend_log_row["spend"] == self.expected_spend
|
||||
assert spend_log_row["spend"] == pytest.approx(self._expected_spend())
|
||||
assert spend_log_row["request_id"] == self.RESPONSE_ID
|
||||
assert spend_log_row["model"] == "gpt-4o-mini"
|
||||
|
||||
|
|
@ -1967,7 +1969,6 @@ class TestOpenAIPassthroughEmbeddingsSpendLog:
|
|||
def setup_method(self):
|
||||
self.start_time = datetime.now()
|
||||
self.end_time = datetime.now()
|
||||
self.expected_spend = self.PROMPT_TOKENS * litellm.model_cost[self.MODEL]["input_cost_per_token"]
|
||||
self.response_body = {
|
||||
"object": "list",
|
||||
"data": [{"object": "embedding", "index": 0, "embedding": [0.0, 1.0]}],
|
||||
|
|
@ -1976,6 +1977,9 @@ class TestOpenAIPassthroughEmbeddingsSpendLog:
|
|||
}
|
||||
self.request_body = {"model": self.MODEL, "input": "hello"}
|
||||
|
||||
def _expected_spend(self) -> float:
|
||||
return self.PROMPT_TOKENS * litellm.model_cost[self.MODEL]["input_cost_per_token"]
|
||||
|
||||
def _create_mock_httpx_response(self) -> httpx.Response:
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
|
|
@ -2001,7 +2005,7 @@ class TestOpenAIPassthroughEmbeddingsSpendLog:
|
|||
)
|
||||
return logging_obj
|
||||
|
||||
def test_embeddings_passthrough_spend_log_is_priced(self):
|
||||
def test_embeddings_passthrough_spend_log_is_priced(self, local_model_cost_map):
|
||||
"""The dispatched call books prompt tokens and cost onto the spend row."""
|
||||
dispatched = PassThroughEndpointLogging().normalize_llm_passthrough_logging_payload(
|
||||
httpx_response=self._create_mock_httpx_response(),
|
||||
|
|
@ -2020,7 +2024,7 @@ class TestOpenAIPassthroughEmbeddingsSpendLog:
|
|||
)
|
||||
|
||||
assert dispatched["standard_logging_response_object"] is not None
|
||||
assert dispatched["kwargs"]["response_cost"] == self.expected_spend
|
||||
assert dispatched["kwargs"]["response_cost"] == pytest.approx(self._expected_spend())
|
||||
|
||||
spend_log_row = get_logging_payload(
|
||||
kwargs=dispatched["kwargs"],
|
||||
|
|
@ -2031,7 +2035,7 @@ class TestOpenAIPassthroughEmbeddingsSpendLog:
|
|||
|
||||
assert spend_log_row["prompt_tokens"] == self.PROMPT_TOKENS
|
||||
assert spend_log_row["total_tokens"] == self.PROMPT_TOKENS
|
||||
assert spend_log_row["spend"] == self.expected_spend
|
||||
assert spend_log_row["spend"] == pytest.approx(self._expected_spend())
|
||||
assert spend_log_row["model"] == self.MODEL
|
||||
assert spend_log_row["custom_llm_provider"] == "openai"
|
||||
assert spend_log_row["request_id"] == self.CALL_ID
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Unit tests for AttachmentRegistry - tests policy attachment matching.
|
|||
Tests the main entry point: get_attached_policies()
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
|
@ -222,6 +223,21 @@ class TestGetAttachedPolicies:
|
|||
# Should only appear once
|
||||
assert attached.count("multi-policy") == 1
|
||||
|
||||
def test_many_distinct_policies_resolve_in_linear_time(self):
|
||||
policy_count = 20_000
|
||||
registry = AttachmentRegistry()
|
||||
registry.load_attachments(
|
||||
[{"policy": f"policy-{index}", "scope": "*"} for index in range(policy_count)]
|
||||
)
|
||||
context = PolicyMatchContext(team_alias="team", key_alias="key", model="gpt-4")
|
||||
|
||||
started = time.perf_counter()
|
||||
attached = registry.get_attached_policies(context)
|
||||
elapsed = time.perf_counter() - started
|
||||
|
||||
assert attached == [f"policy-{index}" for index in range(policy_count)]
|
||||
assert elapsed < 1.0, f"{policy_count} attachments took {elapsed:.2f}s, dedup is no longer one pass"
|
||||
|
||||
def test_no_attachments_returns_empty(self):
|
||||
"""Test empty attachments returns empty list."""
|
||||
registry = AttachmentRegistry()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Pins covered:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -712,22 +713,124 @@ async def test_ProxyConfig__get_config_from_file_missing_path_raises():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ProxyConfig__process_includes_merges_files(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__process_includes_merges_files(tmp_path):
|
||||
inc = tmp_path / "models.yaml"
|
||||
inc.write_text("model_list:\n - model_name: gpt-4\n")
|
||||
pc = ProxyConfig()
|
||||
cfg = {"include": ["models.yaml"], "model_list": [], "litellm_settings": {}}
|
||||
result = pc._process_includes(cfg, base_dir=str(tmp_path))
|
||||
result = await pc._process_includes(cfg, config_file_path=str(tmp_path / "config.yaml"))
|
||||
assert result == {
|
||||
"model_list": [{"model_name": "gpt-4"}],
|
||||
"litellm_settings": {},
|
||||
}
|
||||
|
||||
|
||||
def test_ProxyConfig__process_includes_missing_file_raises(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__process_includes_missing_file_raises(tmp_path):
|
||||
pc = ProxyConfig()
|
||||
with pytest.raises(FileNotFoundError):
|
||||
pc._process_includes({"include": ["nope.yaml"]}, base_dir=str(tmp_path))
|
||||
await pc._process_includes({"include": ["nope.yaml"]}, config_file_path=str(tmp_path / "config.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__process_includes_follows_nested_includes(tmp_path):
|
||||
(tmp_path / "models.yaml").write_text("include:\n - more_models.yaml\nmodel_list:\n - model_name: first\n")
|
||||
(tmp_path / "more_models.yaml").write_text("model_list:\n - model_name: second\n")
|
||||
result = await ProxyConfig()._process_includes(
|
||||
{"include": ["models.yaml"]}, config_file_path=str(tmp_path / "config.yaml")
|
||||
)
|
||||
assert result == {"model_list": [{"model_name": "first"}, {"model_name": "second"}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__process_includes_resolves_a_nested_include_next_to_its_own_file(tmp_path):
|
||||
(tmp_path / "shared").mkdir()
|
||||
(tmp_path / "shared" / "models.yaml").write_text(
|
||||
"include:\n - more_models.yaml\nmodel_list:\n - model_name: first\n"
|
||||
)
|
||||
(tmp_path / "shared" / "more_models.yaml").write_text("model_list:\n - model_name: second\n")
|
||||
(tmp_path / "more_models.yaml").write_text("model_list:\n - model_name: wrong-directory\n")
|
||||
|
||||
result = await ProxyConfig()._process_includes(
|
||||
{"include": ["shared/models.yaml"]}, config_file_path=str(tmp_path / "config.yaml")
|
||||
)
|
||||
|
||||
assert result == {"model_list": [{"model_name": "first"}, {"model_name": "second"}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__process_includes_still_reads_a_nested_include_left_beside_the_root_config(tmp_path):
|
||||
(tmp_path / "shared").mkdir()
|
||||
(tmp_path / "shared" / "models.yaml").write_text(
|
||||
"include:\n - more_models.yaml\nmodel_list:\n - model_name: first\n"
|
||||
)
|
||||
(tmp_path / "more_models.yaml").write_text("model_list:\n - model_name: second\n")
|
||||
|
||||
result = await ProxyConfig()._process_includes(
|
||||
{"include": ["shared/models.yaml"]}, config_file_path=str(tmp_path / "config.yaml")
|
||||
)
|
||||
|
||||
assert result == {"model_list": [{"model_name": "first"}, {"model_name": "second"}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__process_includes_names_both_files_when_a_nested_include_matches_two(tmp_path, caplog):
|
||||
(tmp_path / "shared").mkdir()
|
||||
(tmp_path / "shared" / "models.yaml").write_text(
|
||||
"include:\n - more_models.yaml\nmodel_list:\n - model_name: first\n"
|
||||
)
|
||||
(tmp_path / "shared" / "more_models.yaml").write_text("model_list:\n - model_name: next-to-the-declaring-file\n")
|
||||
(tmp_path / "more_models.yaml").write_text("model_list:\n - model_name: next-to-the-root-config\n")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
||||
result = await ProxyConfig()._process_includes(
|
||||
{"include": ["shared/models.yaml"]}, config_file_path=str(tmp_path / "config.yaml")
|
||||
)
|
||||
|
||||
assert result == {"model_list": [{"model_name": "first"}, {"model_name": "next-to-the-declaring-file"}]}
|
||||
assert [
|
||||
record
|
||||
for record in caplog.records
|
||||
if str(tmp_path / "shared" / "more_models.yaml") in record.getMessage()
|
||||
and str(tmp_path / "more_models.yaml") in record.getMessage()
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__process_includes_merges_a_shared_file_once(tmp_path):
|
||||
(tmp_path / "shared.yaml").write_text("model_list:\n - model_name: shared\n")
|
||||
(tmp_path / "a.yaml").write_text("include:\n - shared.yaml\n")
|
||||
(tmp_path / "b.yaml").write_text("include:\n - ./shared.yaml\n")
|
||||
|
||||
result = await ProxyConfig()._process_includes(
|
||||
{"include": ["a.yaml", "b.yaml"]}, config_file_path=str(tmp_path / "config.yaml")
|
||||
)
|
||||
|
||||
assert result == {"model_list": [{"model_name": "shared"}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__process_includes_names_the_file_when_it_is_not_a_mapping(tmp_path):
|
||||
(tmp_path / "models.yaml").write_text("- model_name: gpt-4\n")
|
||||
|
||||
with pytest.raises(ValueError, match=re.escape(str(tmp_path / "models.yaml"))):
|
||||
await ProxyConfig()._process_includes(
|
||||
{"include": ["models.yaml"]}, config_file_path=str(tmp_path / "config.yaml")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__process_includes_terminates_on_a_cycle(tmp_path):
|
||||
(tmp_path / "a.yaml").write_text("include:\n - b.yaml\nmodel_list:\n - model_name: from-a\n")
|
||||
(tmp_path / "b.yaml").write_text("include:\n - a.yaml\nmodel_list:\n - model_name: from-b\n")
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
ProxyConfig()._process_includes({"include": ["a.yaml"]}, config_file_path=str(tmp_path / "config.yaml")),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert result == {"model_list": [{"model_name": "from-a"}, {"model_name": "from-b"}]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1044,6 +1147,31 @@ async def test_ProxyConfig_get_config_loads_from_file(tmp_path, monkeypatch):
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig_get_config_from_a_bucket_merges_includes(monkeypatch):
|
||||
objects = {
|
||||
"lit6982/config.yaml": {
|
||||
"include": ["model_config.yaml"],
|
||||
"general_settings": {"master_key": "sk-1234"},
|
||||
},
|
||||
"lit6982/model_config.yaml": {"model_list": [{"model_name": "included-model"}]},
|
||||
}
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.common_utils.load_config_utils.s3_object_reader",
|
||||
lambda bucket_name: objects.get,
|
||||
)
|
||||
monkeypatch.setenv("LITELLM_CONFIG_BUCKET_NAME", "litellm-configs")
|
||||
monkeypatch.setenv("LITELLM_CONFIG_BUCKET_OBJECT_KEY", "lit6982/config.yaml")
|
||||
monkeypatch.setenv("LITELLM_CONFIG_BUCKET_TYPE", "s3")
|
||||
|
||||
cfg = await ProxyConfig().get_config()
|
||||
|
||||
assert cfg["model_list"] == [{"model_name": "included-model"}]
|
||||
assert "include" not in cfg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig_get_config_missing_file_raises(monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -64,3 +66,34 @@ class TestPrismaMigration:
|
|||
prisma_migration.main()
|
||||
|
||||
mock_subprocess_run.assert_not_called()
|
||||
|
||||
@patch("litellm.proxy.prisma_migration.subprocess.run") # test-quality-ok: the spawned argv is the behavior under test
|
||||
@patch("litellm.proxy.prisma_migration.run_server") # test-quality-ok: run_server boots the whole proxy
|
||||
def test_prisma_generate_runs_through_the_module_when_the_cli_is_not_on_path(
|
||||
self, mock_run_server: MagicMock, mock_subprocess_run: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_subprocess_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
empty_bin: Path = tmp_path / "emptybin"
|
||||
empty_bin.mkdir()
|
||||
|
||||
with patch.dict(os.environ, {"PATH": str(empty_bin)}, clear=True):
|
||||
assert prisma_migration.main() == 0
|
||||
|
||||
assert mock_subprocess_run.call_args.args[0] == (sys.executable, "-m", "prisma", "generate")
|
||||
|
||||
@patch("litellm.proxy.prisma_migration.subprocess.run") # test-quality-ok: the spawned argv is the behavior under test
|
||||
@patch("litellm.proxy.prisma_migration.run_server") # test-quality-ok: run_server boots the whole proxy
|
||||
def test_prisma_generate_runs_the_console_script_when_it_is_on_path(
|
||||
self, mock_run_server: MagicMock, mock_subprocess_run: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_subprocess_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
bin_dir: Path = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
script: Path = bin_dir / "prisma"
|
||||
script.write_text("#!/bin/sh\nexit 0\n")
|
||||
script.chmod(0o755)
|
||||
|
||||
with patch.dict(os.environ, {"PATH": str(bin_dir)}, clear=True):
|
||||
assert prisma_migration.main() == 0
|
||||
|
||||
assert mock_subprocess_run.call_args.args[0] == ("prisma", "generate")
|
||||
|
|
|
|||
|
|
@ -1940,6 +1940,66 @@ class TestRunServerDbSetup:
|
|||
use_migrate=False, use_v2_resolver=False
|
||||
)
|
||||
|
||||
@patch("atexit.register")
|
||||
@patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above
|
||||
@patch("litellm.proxy.db.check_migration.check_prisma_schema_diff") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above
|
||||
@patch("litellm.proxy.db.prisma_client.should_update_prisma_schema") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above
|
||||
def test_migrations_run_when_the_prisma_cli_is_not_on_path(
|
||||
self,
|
||||
mock_should_update_schema,
|
||||
mock_check_schema_diff,
|
||||
mock_setup_database,
|
||||
mock_atexit_register,
|
||||
tmp_path,
|
||||
capsys,
|
||||
):
|
||||
from litellm.proxy.proxy_cli import run_server
|
||||
|
||||
mock_should_update_schema.return_value = True
|
||||
empty_bin = tmp_path / "emptybin"
|
||||
empty_bin.mkdir()
|
||||
|
||||
mock_proxy_module = MagicMock(
|
||||
app=MagicMock(),
|
||||
ProxyConfig=MagicMock(),
|
||||
KeyManagementSettings=MagicMock(),
|
||||
save_worker_config=MagicMock(),
|
||||
)
|
||||
|
||||
clean_env = {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
if k not in ("DATABASE_URL", "DIRECT_URL")
|
||||
}
|
||||
clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test"
|
||||
clean_env["PATH"] = str(empty_bin)
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, clean_env, clear=True),
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"proxy_server": mock_proxy_module,
|
||||
"litellm.proxy.proxy_server": mock_proxy_module,
|
||||
},
|
||||
),
|
||||
patch( # test-quality-ok: same isolation as the sibling CLI tests above
|
||||
"litellm.proxy.proxy_cli.ProxyInitializationHelpers._get_default_unvicorn_init_args"
|
||||
) as mock_get_args,
|
||||
):
|
||||
mock_get_args.return_value = {
|
||||
"app": "litellm.proxy.proxy_server:app",
|
||||
"host": "localhost",
|
||||
"port": 8000,
|
||||
}
|
||||
|
||||
run_server.main(["--local", "--skip_server_startup"], standalone_mode=False)
|
||||
|
||||
assert "prisma CLI is neither on PATH" not in capsys.readouterr().out
|
||||
mock_setup_database.assert_called_once_with(
|
||||
use_migrate=True, use_v2_resolver=False
|
||||
)
|
||||
|
||||
@patch("subprocess.run")
|
||||
@patch("atexit.register")
|
||||
@patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from types import TracebackType
|
|||
from typing import Final
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
|
|
@ -152,6 +151,33 @@ async def test_vertex_credential_resolution_bounds_a_thread_offloaded_refresh():
|
|||
assert time.monotonic() - start < 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_meta_realtime_dispatches_to_base_handler_with_meta_config(monkeypatch: pytest.MonkeyPatch):
|
||||
from litellm.llms.meta.realtime.transformation import MetaRealtimeConfig
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def mock_get_llm_provider(model, api_base, api_key):
|
||||
return model.removeprefix("meta/"), "meta", None, api_base
|
||||
|
||||
async def mock_async_realtime(**kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(realtime_main, "get_llm_provider", mock_get_llm_provider)
|
||||
monkeypatch.setattr(realtime_main.base_llm_http_handler, "async_realtime", mock_async_realtime)
|
||||
|
||||
await realtime_main._arealtime.__wrapped__(
|
||||
model="meta/muse-voice-transcribe-1.0",
|
||||
websocket=MagicMock(),
|
||||
litellm_logging_obj=FakeLogging(),
|
||||
query_params={"model": "meta/muse-voice-transcribe-1.0", "intent": "transcription"},
|
||||
)
|
||||
|
||||
assert isinstance(captured["provider_config"], MetaRealtimeConfig)
|
||||
assert captured["model"] == "muse-voice-transcribe-1.0"
|
||||
assert captured["query_params"] == {"model": "muse-voice-transcribe-1.0", "intent": "transcription"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arealtime_vertex_branch_resolves_credentials_under_a_bound(monkeypatch):
|
||||
"""The wiring half of the regression: the vertex branch of _arealtime must
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -874,15 +874,25 @@ def test_responses_api_bridge_check_gpt_5_4_tools_with_default_reasoning_routes_
|
|||
assert model_info.get("mode") == "responses"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name", ["gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra"])
|
||||
@pytest.mark.parametrize(
|
||||
"model_name, expected_mode",
|
||||
[
|
||||
pytest.param("gpt-5.6-sol", "responses", id="above-boundary-bridges"),
|
||||
pytest.param("gpt-5.1", None, id="below-boundary-stays-chat"),
|
||||
],
|
||||
)
|
||||
def test_responses_api_bridge_check_gpt_5_6_tools_with_default_reasoning_routes_to_responses(
|
||||
monkeypatch, model_name
|
||||
monkeypatch, model_name, expected_mode
|
||||
):
|
||||
"""
|
||||
The whole gpt-5.6 family must bridge on function tools alone. The bridge used to
|
||||
require an explicit reasoning_effort, so a gpt-5.6 call carrying tools and no effort
|
||||
was rejected with "Function tools with reasoning_effort are not supported for
|
||||
gpt-5.6-sol in /v1/chat/completions".
|
||||
gpt-5.6 must bridge on function tools alone. The bridge used to require an explicit
|
||||
reasoning_effort, so a gpt-5.6 call carrying tools and no effort was rejected with
|
||||
"Function tools with reasoning_effort are not supported for gpt-5.6-sol in
|
||||
/v1/chat/completions".
|
||||
|
||||
Paired with a model below the gpt-5.4 boundary, which must still stay on chat. The
|
||||
gate parses the version and drops any suffix, so the family members bridge
|
||||
identically and only the boundary distinguishes behaviour.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.main import responses_api_bridge_check
|
||||
|
|
@ -901,7 +911,7 @@ def test_responses_api_bridge_check_gpt_5_6_tools_with_default_reasoning_routes_
|
|||
)
|
||||
|
||||
assert model == model_name
|
||||
assert model_info.get("mode") == "responses"
|
||||
assert model_info.get("mode") == expected_mode
|
||||
|
||||
|
||||
def test_responses_api_bridge_check_gpt_5_4_tools_with_reasoning_none_stays_chat():
|
||||
|
|
@ -3311,15 +3321,19 @@ def local_cost_map(monkeypatch):
|
|||
"""The prices these tests assert are the checked-in ones. Setting the environment
|
||||
variable alone does not reload the map, so pin the map itself.
|
||||
|
||||
``get_model_info`` is lru_cached, so pinning ``model_cost`` is not enough on its
|
||||
own: a cached entry warmed against the network-fetched map keeps its old prices
|
||||
and ``completion_cost`` bills at those while the assertions read the pinned map.
|
||||
Clear on the way in and out so entries never leak across tests in either direction."""
|
||||
Prices are read through two separate lru_caches, so pinning ``model_cost`` is not
|
||||
enough on its own: an entry warmed against the network-fetched map keeps its old
|
||||
prices and billing reads those while the assertions read the pinned map.
|
||||
``_invalidate_model_cost_lowercase_map`` clears both caches, where
|
||||
``get_model_info.cache_clear`` reaches only one. Invalidate on the way in and out
|
||||
so entries never leak across tests in either direction."""
|
||||
from litellm.utils import _invalidate_model_cost_lowercase_map
|
||||
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
litellm.get_model_info.cache_clear()
|
||||
_invalidate_model_cost_lowercase_map()
|
||||
yield
|
||||
litellm.get_model_info.cache_clear()
|
||||
_invalidate_model_cost_lowercase_map()
|
||||
|
||||
|
||||
def test_a_streamed_response_bills_the_usage_the_provider_reported(local_cost_map):
|
||||
|
|
|
|||
|
|
@ -4248,9 +4248,6 @@ def test_deepseek_flash_completion_cost():
|
|||
_FIREWORKS_MODELS = [
|
||||
(
|
||||
"accounts/fireworks/models/glm-5p2",
|
||||
1.4e-06,
|
||||
4.4e-06,
|
||||
1.4e-07,
|
||||
1048576,
|
||||
131072,
|
||||
False,
|
||||
|
|
@ -4258,9 +4255,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/models/glm-5p1",
|
||||
1.4e-06,
|
||||
4.4e-06,
|
||||
2.6e-07,
|
||||
202800,
|
||||
131072,
|
||||
False,
|
||||
|
|
@ -4268,9 +4262,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/routers/glm-5p1-fast",
|
||||
2.8e-06,
|
||||
8.8e-06,
|
||||
5.2e-07,
|
||||
202800,
|
||||
131072,
|
||||
False,
|
||||
|
|
@ -4278,9 +4269,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/models/qwen3p7-plus",
|
||||
4e-07,
|
||||
1.6e-06,
|
||||
8e-08,
|
||||
262144,
|
||||
65536,
|
||||
True,
|
||||
|
|
@ -4288,9 +4276,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/models/minimax-m3",
|
||||
3e-07,
|
||||
1.2e-06,
|
||||
6e-08,
|
||||
512000,
|
||||
512000,
|
||||
True,
|
||||
|
|
@ -4298,9 +4283,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/models/minimax-m2p7",
|
||||
3e-07,
|
||||
1.2e-06,
|
||||
6e-08,
|
||||
196608,
|
||||
196608,
|
||||
False,
|
||||
|
|
@ -4308,9 +4290,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/models/kimi-k2p7-code",
|
||||
9.5e-07,
|
||||
4e-06,
|
||||
1.9e-07,
|
||||
262144,
|
||||
32768,
|
||||
True,
|
||||
|
|
@ -4318,9 +4297,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/routers/kimi-k2p7-code-fast",
|
||||
1.9e-06,
|
||||
8e-06,
|
||||
3.8e-07,
|
||||
262144,
|
||||
32768,
|
||||
True,
|
||||
|
|
@ -4328,9 +4304,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/models/kimi-k2p6",
|
||||
9.5e-07,
|
||||
4e-06,
|
||||
1.6e-07,
|
||||
262144,
|
||||
32768,
|
||||
True,
|
||||
|
|
@ -4338,9 +4311,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/routers/kimi-k2p6-fast",
|
||||
2e-06,
|
||||
8e-06,
|
||||
3e-07,
|
||||
262144,
|
||||
32768,
|
||||
True,
|
||||
|
|
@ -4348,9 +4318,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/models/gpt-oss-120b",
|
||||
1.5e-07,
|
||||
6e-07,
|
||||
1.5e-08,
|
||||
131072,
|
||||
32768,
|
||||
False,
|
||||
|
|
@ -4358,9 +4325,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/models/gpt-oss-20b",
|
||||
7e-08,
|
||||
3e-07,
|
||||
3.5e-08,
|
||||
131072,
|
||||
32768,
|
||||
False,
|
||||
|
|
@ -4368,9 +4332,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/models/deepseek-v4-pro",
|
||||
1.74e-06,
|
||||
3.48e-06,
|
||||
1.45e-07,
|
||||
1048576,
|
||||
384000,
|
||||
False,
|
||||
|
|
@ -4378,9 +4339,6 @@ _FIREWORKS_MODELS = [
|
|||
),
|
||||
(
|
||||
"accounts/fireworks/models/deepseek-v4-flash",
|
||||
1.4e-07,
|
||||
2.8e-07,
|
||||
2.8e-08,
|
||||
1048576,
|
||||
384000,
|
||||
False,
|
||||
|
|
@ -4412,9 +4370,6 @@ _FIREWORKS_ROUTER_SHORT_FORMS = [
|
|||
def _assert_fireworks_entry(
|
||||
model_cost,
|
||||
model_path,
|
||||
expected_input,
|
||||
expected_output,
|
||||
expected_cache,
|
||||
expected_max_input,
|
||||
expected_max_output,
|
||||
expected_vision,
|
||||
|
|
@ -4424,9 +4379,9 @@ def _assert_fireworks_entry(
|
|||
assert info is not None, f"fireworks_ai/{model_path} missing from model cost map"
|
||||
assert info["litellm_provider"] == "fireworks_ai"
|
||||
assert info["mode"] == "chat"
|
||||
assert info["input_cost_per_token"] == expected_input
|
||||
assert info["output_cost_per_token"] == expected_output
|
||||
assert info["cache_read_input_token_cost"] == expected_cache
|
||||
assert info["input_cost_per_token"] > 0
|
||||
assert info["output_cost_per_token"] > 0
|
||||
assert "cache_read_input_token_cost" in info
|
||||
assert info["max_input_tokens"] == expected_max_input
|
||||
assert info["max_output_tokens"] == expected_max_output
|
||||
assert info["max_tokens"] == expected_max_output
|
||||
|
|
|
|||
|
|
@ -79,6 +79,20 @@ async def test_proxy_metadata_remains_python_owned(ocr_server: RecordingServer)
|
|||
assert "metadata" not in ocr_server.requests[0].body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_level_custom_pricing_reaches_logging_params_and_bills_the_call(
|
||||
ocr_server: RecordingServer,
|
||||
) -> None:
|
||||
recorder: Final = RecordingLogger()
|
||||
response: Final = await call_aocr(ocr_server, callbacks=[recorder], ocr_cost_per_page=0.05)
|
||||
events: Final = await recorder.wait_for_async("async_log_success_event")
|
||||
|
||||
assert response.usage_info is not None and response.usage_info.pages_processed == 1
|
||||
assert events[0].kwargs["litellm_params"]["ocr_cost_per_page"] == 0.05
|
||||
assert response._hidden_params["response_cost"] == pytest.approx(0.05)
|
||||
assert "ocr_cost_per_page" not in ocr_server.requests[0].body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_replacement_finalized_before_dispatch_in_caller_task(ocr_server: RecordingServer) -> None:
|
||||
caller: Final = asyncio.current_task()
|
||||
|
|
|
|||
|
|
@ -152,6 +152,28 @@ describe("useResourceList", () => {
|
|||
await waitFor(() => expect(lastCall().page_size).toBe(25));
|
||||
});
|
||||
|
||||
it("reports loading while a new search request is still pending", async () => {
|
||||
let resolveSecond: ((value: ResourceListPage<Row>) => void) | undefined;
|
||||
const fetchPage = vi.fn((query: ResourceListQuery) => {
|
||||
calls.push(query);
|
||||
if (calls.length === 1) return Promise.resolve(page([{ id: "a" }], 3));
|
||||
return new Promise<ResourceListPage<Row>>((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
});
|
||||
});
|
||||
const { result } = renderList({ fetchPage });
|
||||
await waitFor(() => expect(result.current.rows).toEqual([{ id: "a" }]));
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
|
||||
act(() => result.current.onSearchChange("zzz"));
|
||||
await waitFor(() => expect(lastCall().q).toBe("zzz"));
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
act(() => resolveSecond?.(page([], 0)));
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(result.current.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces a failed page as an error instead of empty rows", async () => {
|
||||
const fetchPage = vi.fn(() => Promise.reject(new Error("boom")));
|
||||
const { result } = renderList({ fetchPage });
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export function useResourceList<TRow>(options: UseResourceListOptions<TRow>): Re
|
|||
enabled,
|
||||
placeholderData: (previous) => previous,
|
||||
};
|
||||
const { data, isLoading, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions);
|
||||
const { data, isLoading, isPlaceholderData, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions);
|
||||
|
||||
const toFirstPage = useCallback(() => setPagination((previous) => ({ ...previous, pageIndex: 0 })), []);
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ export function useResourceList<TRow>(options: UseResourceListOptions<TRow>): Re
|
|||
return {
|
||||
rows,
|
||||
rowCount: data?.meta.total_count ?? 0,
|
||||
isLoading,
|
||||
isLoading: isLoading || isPlaceholderData,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface ProjectUpdateParams {
|
|||
description?: string;
|
||||
team_id?: string;
|
||||
models?: string[];
|
||||
max_budget?: number;
|
||||
max_budget?: number | null;
|
||||
blocked?: boolean;
|
||||
guardrails?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
|
|
|
|||
|
|
@ -98,7 +98,11 @@ const AccessGroupBudgetModal: React.FC<AccessGroupBudgetModalProps> = ({
|
|||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<BudgetDurationDropdown id={id} value={value || null} onChange={onChange} />
|
||||
<BudgetDurationDropdown
|
||||
id={id}
|
||||
value={value || null}
|
||||
onChange={(next) => onChange(next ?? undefined)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
|
|
|||
|
|
@ -102,6 +102,20 @@ describe("EditProjectModal submit payload", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should send an explicit clear after blanking a saved budget", async () => {
|
||||
const user = setup();
|
||||
renderModal();
|
||||
|
||||
const budgetInput = screen.getByRole("spinbutton", { name: "Max Budget (USD)" });
|
||||
await user.clear(budgetInput);
|
||||
await user.tab();
|
||||
expect(budgetInput).toHaveValue(null);
|
||||
await save(user);
|
||||
|
||||
await waitFor(() => expect(mutate).toHaveBeenCalled());
|
||||
expect(JSON.parse(JSON.stringify(variables().params))).toMatchObject({ max_budget: null });
|
||||
});
|
||||
|
||||
it("includes the advanced fields once Advanced Settings has been opened, even after collapsing it again", async () => {
|
||||
const user = setup();
|
||||
renderModal();
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalP
|
|||
: { ...values, guardrails: undefined, modelLimits: undefined, metadata: undefined };
|
||||
|
||||
const params: ProjectUpdateParams = {
|
||||
...buildProjectUpdateParams(submitted),
|
||||
...buildProjectUpdateParams(submitted, project.litellm_budget_table?.max_budget),
|
||||
team_id: submitted.team_id,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -205,8 +205,19 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
|
|||
type="number"
|
||||
min={0}
|
||||
placeholder="0.00"
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange(toOptionalNumber(event.target.value))}
|
||||
value={Number.isNaN(value) ? "" : value ?? ""}
|
||||
onInput={(event) => {
|
||||
if (event.currentTarget.validity.badInput || Number.isNaN(value)) {
|
||||
onChange(
|
||||
event.currentTarget.validity.badInput
|
||||
? Number.NaN
|
||||
: toOptionalNumber(event.currentTarget.value) ?? null,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={(event) =>
|
||||
onChange(event.target.validity.badInput ? Number.NaN : toOptionalNumber(event.target.value) ?? null)
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const projectFormSchema = z
|
|||
.pipe(z.string({ error: "Please select a team" }).min(1, "Please select a team")),
|
||||
description: z.string().optional(),
|
||||
models: z.array(z.string()),
|
||||
max_budget: z.number().optional(),
|
||||
max_budget: z.number().nullish(),
|
||||
isBlocked: z.boolean(),
|
||||
guardrails: z.array(z.string()).optional(),
|
||||
modelLimits: z.array(modelLimitSchema).optional(),
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ describe("buildProjectCreateParams", () => {
|
|||
expect(result.description).toBe("A description");
|
||||
});
|
||||
|
||||
it("should pass through max_budget when provided", () => {
|
||||
const result = buildProjectCreateParams({ ...baseValues, max_budget: 50.0 });
|
||||
expect(result.max_budget).toBe(50.0);
|
||||
it.each([50.0, 1e308])("should preserve a finite max_budget of %s", (maxBudget) => {
|
||||
const result = buildProjectCreateParams({ ...baseValues, max_budget: maxBudget });
|
||||
expect(JSON.parse(JSON.stringify(result)).max_budget).toBe(maxBudget);
|
||||
});
|
||||
|
||||
it("should build model_rpm_limit from modelLimits entries", () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ const buildModelLimitMap = (
|
|||
const buildMetadata = (entries: ProjectFormValues["metadata"]): Record<string, string> | undefined =>
|
||||
entries && Object.fromEntries(entries.flatMap((entry) => (entry.key ? [[entry.key, entry.value] as const] : [])));
|
||||
|
||||
const roundBudget = (value: number): number => {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
return Number.isFinite(rounded) ? rounded : value;
|
||||
};
|
||||
|
||||
const buildProjectApiParams = (values: ProjectFormValues, sendEmpty: boolean) => {
|
||||
const limitEntries = values.modelLimits ?? [];
|
||||
const modelRpmLimit = buildModelLimitMap(limitEntries, (entry) => entry.rpm);
|
||||
|
|
@ -35,7 +40,7 @@ const buildProjectApiParams = (values: ProjectFormValues, sendEmpty: boolean) =>
|
|||
project_alias: values.project_alias,
|
||||
description: values.description,
|
||||
models: values.models ?? [],
|
||||
max_budget: values.max_budget === undefined ? undefined : Math.round(values.max_budget * 100) / 100,
|
||||
max_budget: values.max_budget == null ? undefined : roundBudget(values.max_budget),
|
||||
blocked: values.isBlocked ?? false,
|
||||
...guardrailsParam,
|
||||
...(keep(modelRpmLimit) && { model_rpm_limit: modelRpmLimit }),
|
||||
|
|
@ -53,4 +58,7 @@ export const buildProjectCreateParams = (values: ProjectFormValues) => buildProj
|
|||
* /project/update leaves an omitted key untouched, so a limit the operator cleared has to go out as
|
||||
* an explicitly empty map. Omitting it is what silently kept a removed quota enforced.
|
||||
*/
|
||||
export const buildProjectUpdateParams = (values: ProjectFormValues) => buildProjectApiParams(values, true);
|
||||
export const buildProjectUpdateParams = (values: ProjectFormValues, savedMaxBudget?: number | null) => ({
|
||||
...buildProjectApiParams(values, true),
|
||||
...(values.max_budget == null && savedMaxBudget != null ? { max_budget: null } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -143,7 +143,11 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<BudgetDurationDropdown id={id} value={value ?? null} onChange={onChange} />
|
||||
<BudgetDurationDropdown
|
||||
id={id}
|
||||
value={value ?? null}
|
||||
onChange={(next) => onChange(next ?? undefined)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const tagEditShape = {
|
|||
description: z.string().optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
max_budget: z.union([z.string(), z.number()]).optional(),
|
||||
budget_duration: z.string().optional(),
|
||||
budget_duration: z.string().nullish(),
|
||||
};
|
||||
|
||||
const tagEditSchema = z.object(tagEditShape);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatte
|
|||
import EndpointUsage from "../EndpointUsage/EndpointUsage";
|
||||
import ModelViewToggle, { ModelViewType } from "../ModelViewToggle";
|
||||
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
|
||||
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
|
||||
import TopModelView from "./TopModelView";
|
||||
import TeamUserSpendCard from "./TeamUserSpendCard";
|
||||
|
||||
|
|
@ -654,7 +655,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
{
|
||||
key: "keys",
|
||||
label: "Key Activity",
|
||||
content: <ActivityMetrics modelMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />,
|
||||
content: <KeyActivityPanel keyMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />,
|
||||
},
|
||||
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr
|
|||
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
|
||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||
import EntityUsageExportModal from "@/components/EntityUsageExport";
|
||||
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import {
|
||||
gatewayDailyActivityCall,
|
||||
|
|
@ -886,7 +887,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<ActivityMetrics modelMetrics={modelMetrics} />
|
||||
</TabsContent>
|
||||
<TabsContent value="keys" keepMounted>
|
||||
<ActivityMetrics modelMetrics={keyMetrics} />
|
||||
<KeyActivityPanel keyMetrics={keyMetrics} />
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp" keepMounted>
|
||||
<ActivityMetrics modelMetrics={mcpServerMetrics} />
|
||||
|
|
|
|||
|
|
@ -344,5 +344,16 @@ describe("ViewUserDashboard", () => {
|
|||
expect(latest[4]).toBeNull();
|
||||
expect(latest[2]).toBe(1);
|
||||
});
|
||||
|
||||
it("replaces the previous rows with the loading state while the search request is pending", async () => {
|
||||
renderDashboard();
|
||||
expect(await screen.findByText("test@example.com")).toBeInTheDocument();
|
||||
|
||||
userListCall.mockReturnValue(new Promise(() => undefined));
|
||||
fireEvent.change(screen.getByPlaceholderText("Search by email or ID…"), { target: { value: "zzznomatch" } });
|
||||
|
||||
expect(await screen.findByText("Loading users…")).toBeInTheDocument();
|
||||
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
<UsersTable
|
||||
data={users}
|
||||
rowCount={totalUserCount}
|
||||
isLoading={userListQuery.isLoading}
|
||||
isLoading={userListQuery.isLoading || userListQuery.isPlaceholderData}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
teams={teams}
|
||||
sorting={sorting}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, renderWithProviders as render, screen, waitFor } from "../../../../../../tests/test-utils";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import UserInfoView from "./user_info_view";
|
||||
|
|
@ -163,6 +163,35 @@ describe("UserInfoView add-to-team form", () => {
|
|||
|
||||
expect(await openEditor(user)).toHaveValue(42);
|
||||
});
|
||||
|
||||
it("should keep Unlimited selected after saving and reopening the user", async () => {
|
||||
const user = setup();
|
||||
render(<UserInfoView {...budgetProps} />);
|
||||
|
||||
await openEditor(user);
|
||||
await user.click(screen.getByRole("checkbox", { name: "Unlimited Budget" }));
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(mockUserUpdateUserCall).toHaveBeenCalled());
|
||||
expect(mockUserUpdateUserCall.mock.calls[0][1]).toMatchObject({ max_budget: null });
|
||||
await openEditor(user);
|
||||
expect(screen.getByRole("checkbox", { name: "Unlimited Budget" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("should keep a cleared reset period after saving and reopening the user", async () => {
|
||||
const user = setup();
|
||||
render(<UserInfoView {...budgetProps} />);
|
||||
|
||||
await openEditor(user);
|
||||
await user.click(screen.getByRole("combobox", { name: "Reset Budget" }));
|
||||
await user.click(await screen.findByRole("option", { name: "n/a" }));
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(mockUserUpdateUserCall).toHaveBeenCalled());
|
||||
expect(mockUserUpdateUserCall.mock.calls[0][1]).toMatchObject({ budget_duration: null });
|
||||
await openEditor(user);
|
||||
expect(screen.getByRole("combobox", { name: "Reset Budget" })).toHaveTextContent("n/a");
|
||||
});
|
||||
});
|
||||
|
||||
it("offers only the teams the user is not already a member of", async () => {
|
||||
|
|
|
|||
|
|
@ -332,8 +332,9 @@ export default function UserInfoView({
|
|||
user_email: formValues.user_email ?? userData.user_email,
|
||||
user_alias: formValues.user_alias ?? userData.user_alias,
|
||||
models: formValues.models ?? userData.models,
|
||||
max_budget: formValues.max_budget ?? userData.max_budget,
|
||||
budget_duration: formValues.budget_duration ?? userData.budget_duration,
|
||||
max_budget: formValues.max_budget === undefined ? userData.max_budget : formValues.max_budget,
|
||||
budget_duration:
|
||||
formValues.budget_duration === undefined ? userData.budget_duration : formValues.budget_duration,
|
||||
metadata: formValues.metadata ?? userData.metadata,
|
||||
model_max_budget: formValues.model_max_budget ?? userData.model_max_budget,
|
||||
object_permission: mcpEntitlement
|
||||
|
|
|
|||
|
|
@ -807,7 +807,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
showNeverResets
|
||||
placeholder={budgetDurationPlaceholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={(next) => onChange(next ?? undefined)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -138,6 +138,14 @@ it("shows a loading state on initial load and hides the data", () => {
|
|||
expect(screen.queryByText("Acme Team")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces the previous rows with the loading state while a new search is pending", () => {
|
||||
mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { isPlaceholderData: true, isFetching: true }));
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByText("Loading teams...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Acme Team")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("sort contract – only backend-sortable columns are sortable", () => {
|
||||
it("requests the default created_at descending sort on first render", () => {
|
||||
renderTable();
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
|
|||
|
||||
const {
|
||||
data: teamsResponse,
|
||||
isPending: isLoading,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useTeamsTable(tablePagination.pageIndex + 1, tablePagination.pageSize, teamListOptions);
|
||||
|
|
@ -161,7 +162,7 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
|
|||
onColumnFiltersChange={handleColumnFiltersChange}
|
||||
enableColumnResizing
|
||||
columnResizeMode="onChange"
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending || isPlaceholderData}
|
||||
loadingMessage="Loading teams..."
|
||||
noDataMessage="No teams found"
|
||||
fillHeight
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ModelActivityData } from "../types";
|
||||
import KeyActivityPanel from "./KeyActivityPanel";
|
||||
|
||||
vi.mock("@/components/activity_metrics", () => ({
|
||||
ActivityMetrics: ({ modelMetrics }: { modelMetrics: Record<string, ModelActivityData> }) => (
|
||||
<ul data-testid="rendered-keys">
|
||||
{Object.keys(modelMetrics).map((hash) => (
|
||||
<li key={hash}>{hash}</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
}));
|
||||
|
||||
function activity(label: string, user_email: string | null, user_id: string | null): ModelActivityData {
|
||||
return {
|
||||
label,
|
||||
key_metadata: { key_alias: label, team_id: "team-1", user_id, user_email },
|
||||
total_requests: 1,
|
||||
total_successful_requests: 1,
|
||||
total_failed_requests: 0,
|
||||
total_cache_read_input_tokens: 0,
|
||||
total_cache_creation_input_tokens: 0,
|
||||
total_tokens: 10,
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 5,
|
||||
total_spend: 0.01,
|
||||
top_api_keys: [],
|
||||
top_models: [],
|
||||
daily_data: [],
|
||||
};
|
||||
}
|
||||
|
||||
const keyMetrics: Record<string, ModelActivityData> = {
|
||||
"hash-alice": activity("alice-key", "alice@example.com", "user-alice"),
|
||||
"hash-bob": activity("bob-key", "bob@example.com", "user-bob"),
|
||||
};
|
||||
|
||||
describe("KeyActivityPanel", () => {
|
||||
it("renders every key and the full count before searching", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
|
||||
expect(screen.getByText("Showing 2 of 2 keys")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("narrows the rendered keys to those matching the user email", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "bob@example.com" } });
|
||||
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-bob");
|
||||
expect(screen.getByTestId("rendered-keys")).not.toHaveTextContent("hash-alice");
|
||||
expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state instead of zeroed metrics when nothing matches", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "carol" } });
|
||||
expect(screen.queryByTestId("rendered-keys")).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No keys match "carol" in this date range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the search and restores every key", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "user-alice" } });
|
||||
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alice");
|
||||
fireEvent.click(screen.getByLabelText("Clear key search"));
|
||||
expect(screen.getByLabelText("Search keys")).toHaveValue("");
|
||||
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { Search, X } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { ActivityMetrics } from "@/components/activity_metrics";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
||||
import { filterKeyActivity } from "../keyActivityFilter";
|
||||
import type { ModelActivityData } from "../types";
|
||||
|
||||
interface KeyActivityPanelProps {
|
||||
keyMetrics: Record<string, ModelActivityData>;
|
||||
hidePromptCachingMetrics?: boolean;
|
||||
}
|
||||
|
||||
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePromptCachingMetrics = false }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]);
|
||||
const totalKeys = Object.keys(keyMetrics).length;
|
||||
const shownKeys = Object.keys(filtered).length;
|
||||
const isFiltering = query.trim() !== "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<InputGroup className="max-w-md">
|
||||
<InputGroupAddon>
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
aria-label="Search keys"
|
||||
placeholder="Search by key alias, key hash, user ID, or email"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{isFiltering && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear key search" onClick={() => setQuery("")}>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
|
||||
</span>
|
||||
</div>
|
||||
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
|
||||
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
||||
No keys match "{query.trim()}" in this date range
|
||||
</p>
|
||||
) : (
|
||||
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyActivityPanel;
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { filterKeyActivity, keyActivityMatches } from "./keyActivityFilter";
|
||||
import type { KeyMetadata, ModelActivityData } from "./types";
|
||||
|
||||
function activity(label: string, key_metadata?: KeyMetadata): ModelActivityData {
|
||||
return {
|
||||
label,
|
||||
key_metadata,
|
||||
total_requests: 1,
|
||||
total_successful_requests: 1,
|
||||
total_failed_requests: 0,
|
||||
total_cache_read_input_tokens: 0,
|
||||
total_cache_creation_input_tokens: 0,
|
||||
total_tokens: 10,
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 5,
|
||||
total_spend: 0.01,
|
||||
top_api_keys: [],
|
||||
top_models: [],
|
||||
daily_data: [],
|
||||
};
|
||||
}
|
||||
|
||||
const aliceMeta: KeyMetadata = {
|
||||
key_alias: "alice-batch",
|
||||
team_id: "team-research",
|
||||
user_id: "user-alice-1234",
|
||||
user_email: "alice@example.com",
|
||||
};
|
||||
const bobMeta: KeyMetadata = {
|
||||
key_alias: null,
|
||||
team_id: "team-research",
|
||||
user_id: "user-bob-5678",
|
||||
user_email: "bob@example.com",
|
||||
};
|
||||
const alice = activity("alice-batch (team: research)", aliceMeta);
|
||||
const bob = activity("bob@example.com (team: research)", bobMeta);
|
||||
const orphan = activity("key-hash-deadbeef", { key_alias: null, team_id: null });
|
||||
|
||||
const keyMetrics: Record<string, ModelActivityData> = {
|
||||
"hash-alice": alice,
|
||||
"hash-bob": bob,
|
||||
deadbeef: orphan,
|
||||
};
|
||||
|
||||
describe("keyActivityMatches", () => {
|
||||
it("matches every key on an empty or whitespace query", () => {
|
||||
expect(keyActivityMatches("deadbeef", orphan, "")).toBe(true);
|
||||
expect(keyActivityMatches("deadbeef", orphan, " ")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches key alias case-insensitively", () => {
|
||||
expect(keyActivityMatches("hash-alice", alice, "ALICE-batch")).toBe(true);
|
||||
expect(keyActivityMatches("hash-bob", bob, "alice-batch")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches user email", () => {
|
||||
expect(keyActivityMatches("hash-bob", bob, "bob@example")).toBe(true);
|
||||
expect(keyActivityMatches("hash-alice", alice, "bob@example")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches user id", () => {
|
||||
expect(keyActivityMatches("hash-alice", alice, "user-alice-1234")).toBe(true);
|
||||
expect(keyActivityMatches("hash-bob", bob, "user-alice-1234")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches the key hash when the key has no alias or user metadata", () => {
|
||||
expect(keyActivityMatches("deadbeef", orphan, "dead")).toBe(true);
|
||||
expect(keyActivityMatches("deadbeef", orphan, "alice")).toBe(false);
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from the query", () => {
|
||||
expect(keyActivityMatches("hash-alice", alice, " alice@example.com ")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterKeyActivity", () => {
|
||||
it("returns the same object when the query is blank", () => {
|
||||
expect(filterKeyActivity(keyMetrics, "")).toBe(keyMetrics);
|
||||
});
|
||||
|
||||
it("keeps only the keys matching the query, preserving their hashes", () => {
|
||||
expect(Object.keys(filterKeyActivity(keyMetrics, "example.com"))).toEqual(["hash-alice", "hash-bob"]);
|
||||
expect(filterKeyActivity(keyMetrics, "user-bob")).toEqual({ "hash-bob": bob });
|
||||
});
|
||||
|
||||
it("returns an empty record when nothing matches", () => {
|
||||
expect(filterKeyActivity(keyMetrics, "nobody")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type { ModelActivityData } from "./types";
|
||||
|
||||
export function keyActivityMatches(apiKey: string, data: ModelActivityData, query: string): boolean {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (needle === "") return true;
|
||||
const meta = data.key_metadata;
|
||||
return [apiKey, data.label, meta?.key_alias, meta?.user_id, meta?.user_email].some(
|
||||
(field) => field?.toLowerCase().includes(needle) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
export function filterKeyActivity(
|
||||
keyMetrics: Record<string, ModelActivityData>,
|
||||
query: string,
|
||||
): Record<string, ModelActivityData> {
|
||||
if (query.trim() === "") return keyMetrics;
|
||||
return Object.fromEntries(
|
||||
Object.entries(keyMetrics).filter(([apiKey, data]) => keyActivityMatches(apiKey, data, query)),
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue