Merge remote-tracking branch 'origin/main' into litellm_azure_entra_token_followups

# Conflicts:
#	litellm/llms/azure/common_utils.py
#	tests/unit/llms/azure/test_azure_common_utils.py
This commit is contained in:
mateo-berri 2026-10-19 13:36:55 -07:00
commit 57f6534a92
90 changed files with 8567 additions and 584 deletions

View file

@ -70,11 +70,18 @@ proxyConfigMap:
proxy_config:
general_settings:
master_key: os.environ/PROXY_MASTER_KEY
model_list: []
```
The chart ships no models, so add them under `proxy_config.model_list` or from the Admin UI with `store_model_in_db: true`
```
proxy_config:
model_list:
- model_name: gpt-3.5-turbo
- model_name: gpt-5.6
litellm_params:
model: gpt-3.5-turbo
api_key: eXaMpLeOnLy
model: openai/gpt-5.6
api_key: os.environ/OPENAI_API_KEY
```
#### Example using existing `proxyConfigMap` instead of creating it:

View file

@ -270,6 +270,32 @@ tests:
- matchRegex:
path: data["config.yaml"]
pattern: '(?m)^\s*health_check_details:\s*false$'
- it: should not render any placeholder models when the user sets proxy_config without model_list
template: configmap-litellm.yaml
set:
proxy_config.general_settings.store_model_in_db: true
asserts:
- notMatchRegex:
path: data["config.yaml"]
pattern: 'model_name:'
- matchRegex:
path: data["config.yaml"]
pattern: 'store_model_in_db: true'
- matchRegex:
path: data["config.yaml"]
pattern: 'master_key: os.environ/PROXY_MASTER_KEY'
- it: should render a user-supplied model_list as the only models
template: configmap-litellm.yaml
set:
proxy_config.model_list:
- model_name: my-model
litellm_params:
model: openai/my-model
api_key: os.environ/OPENAI_API_KEY
asserts:
- matchRegex:
path: data["config.yaml"]
pattern: 'model_list:\n- litellm_params:\n api_key: os.environ/OPENAI_API_KEY\n model: openai/my-model\n model_name: my-model\n$'
- it: should allow overriding liveness, readiness, and startup probes
template: deployment.yaml
set:

View file

@ -180,17 +180,7 @@ proxyConfigMap:
# Examples: https://github.com/BerriAI/litellm/tree/main/litellm/proxy/example_config_yaml
# Reference: https://docs.litellm.ai/docs/proxy/configs
proxy_config:
model_list:
# At least one model must exist for the proxy to start.
- model_name: gpt-3.5-turbo
litellm_params:
model: gpt-3.5-turbo
api_key: eXaMpLeOnLy
- model_name: fake-openai-endpoint
litellm_params:
model: openai/fake
api_key: fake-key
api_base: https://exampleopenaiendpoint-production.up.railway.app/
model_list: []
general_settings:
master_key: os.environ/PROXY_MASTER_KEY

View file

@ -7,6 +7,7 @@ _apply_process_defaults()
### INIT VARIABLES #########################
import threading
import os
import datetime
from collections.abc import Mapping, Sequence
from typing import (
@ -62,7 +63,6 @@ from litellm.constants import (
modelscope_models,
empower_models,
together_ai_models,
baseten_models,
WANDB_MODELS,
REPEATED_STREAMING_CHUNK_LIMIT,
request_timeout,
@ -624,6 +624,7 @@ bedrock_models: Set = set()
bedrock_converse_models: Set = set(BEDROCK_CONVERSE_MODELS)
fal_ai_models: Set = set()
fireworks_ai_models: Set = set()
baseten_models: Set[str] = set()
fireworks_ai_embedding_models: Set = set()
deepinfra_models: Set = set()
perplexity_models: Set = set()
@ -742,6 +743,16 @@ def is_openai_finetune_model(key: str) -> bool:
return key.startswith("ft:") and not key.count(":") > 1
def _is_past_deprecation_date(value: Mapping[str, object]) -> bool:
deprecation_date: Final = value.get("deprecation_date")
if not isinstance(deprecation_date, str):
return False
try:
return datetime.date.fromisoformat(deprecation_date) <= datetime.date.today()
except ValueError:
return False
def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, object]]) -> None:
for key, value in tuple(model_cost_map.items()):
if value.get("litellm_provider") == "openai" and not is_openai_finetune_model(key):
@ -842,6 +853,11 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje
# ignore the 'up-to', '-to-' model names -> not real models. just for cost tracking based on model params.
if "-to-" not in key and "fireworks-ai-default" not in key:
fireworks_ai_models.add(key)
elif value.get("litellm_provider") == "baseten":
if _is_past_deprecation_date(value):
baseten_models.discard(key)
else:
baseten_models.add(key)
elif value.get("litellm_provider") == "fireworks_ai-embedding-models":
# ignore the 'up-to', '-to-' model names -> not real models. just for cost tracking based on model params.
if "-to-" not in key:

View file

@ -40,6 +40,14 @@ class ServerStreamingClassification(str, Enum):
SERVER_STREAMING_CLASSIFICATION_MARKER: Final = ServerStreamingClassification.MARKER
DEFER_PYDANTIC_BUILD: Final = os.getenv("DEFER_PYDANTIC_BUILD", "true") in ("true", "1", "on")
DEFAULT_HEALTH_CHECK_PROMPT: Final = str(os.getenv("DEFAULT_HEALTH_CHECK_PROMPT", "test from litellm"))
KUBERNETES_POD_DISCOVERY_REFRESH_INTERVAL_SECONDS: Final = get_env_float(
"KUBERNETES_POD_DISCOVERY_REFRESH_INTERVAL_SECONDS", 5.0
)
KUBERNETES_POD_DISCOVERY_IDLE_EVICTION_SECONDS: Final = get_env_float(
"KUBERNETES_POD_DISCOVERY_IDLE_EVICTION_SECONDS", 300.0
)
KUBERNETES_POD_DISCOVERY_DNS_TIMEOUT_SECONDS: Final = 2.0
KUBERNETES_POD_ROUTING_KEY: Final = "kubernetes_pod_routing"
AZURE_DEFAULT_RESPONSES_API_VERSION: Final = str(os.getenv("AZURE_DEFAULT_RESPONSES_API_VERSION", "preview"))
AZURE_OPENAI_AUDIO_PROVIDERS: Final = frozenset({"azure", "azure_ai"})
ROUTER_MAX_FALLBACKS: Final = get_env_int("ROUTER_MAX_FALLBACKS", 5)
@ -1256,14 +1264,6 @@ together_ai_models: Final[set] = set(
# supports all together ai models, just pass in the model id e.g. completion(model="together_computer/replit_code_3b",...)
baseten_models: Final[set] = set(
[
"qvv0xeq",
"q841o8w",
"31dxrj3",
]
) # FALCON 7B # WizardLM # Mosaic ML
featherless_ai_models: Final[set] = set(
[
"featherless-ai/Qwerky-72B",

View file

@ -13,6 +13,7 @@ from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_logger
from litellm.constants import KUBERNETES_POD_ROUTING_KEY
from litellm.integrations._types.open_inference import (
OpenInferenceSpanKindValues,
)
@ -50,6 +51,7 @@ from litellm.types.utils import (
Function,
LLMResponseTypes,
StandardCallbackDynamicParams,
StandardLoggingMetadata,
StandardLoggingPayload,
)
@ -2420,9 +2422,15 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
#############################################
############ LLM CALL METADATA ##############
#############################################
metadata: Final = standard_logging_payload["metadata"]
metadata: Final = cast( # cast-ok: payload metadata follows the standard logging contract
StandardLoggingMetadata, standard_logging_payload["metadata"]
)
for key, value in metadata.items():
self.safe_set_attribute(span=span, key=f"metadata.{key}", value=value)
self.safe_set_attribute(
span=span,
key=f"metadata.{key}",
value=safe_dumps(value) if key == KUBERNETES_POD_ROUTING_KEY and value is not None else value,
)
decision: Final = metadata.get("routing_decision")
span.set_attributes(routing_decision_attributes(decision))

View file

@ -11,13 +11,14 @@ import httpx
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_logger
from litellm.constants import KUBERNETES_POD_ROUTING_KEY
from litellm.litellm_core_utils.llm_response_utils.get_headers import get_provider_request_id
from litellm.types.llms.openai import AllMessageValues, OpenAIChatCompletionFinishReason
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
from litellm.types.utils import ModelResponseStream
from litellm.types.utils import ModelResponseStream, StandardLoggingKubernetesPodRouting
Span = _Span | Any
else:
@ -385,6 +386,54 @@ def proxy_stamped_used_client_oauth_token(metadata: object, litellm_params: Mapp
return metadata.get("used_client_oauth_token") if isinstance(metadata, Mapping) else None
def proxy_stamped_kubernetes_pod_routing(
metadata: object,
litellm_params: Mapping[str, object] | None,
) -> "StandardLoggingKubernetesPodRouting | None":
litellm_metadata: Final = litellm_params.get("litellm_metadata") if litellm_params is not None else None
litellm_routing: Final = _standard_logging_kubernetes_pod_routing(litellm_metadata)
if litellm_routing is not None:
return litellm_routing
return _standard_logging_kubernetes_pod_routing(metadata)
def _standard_logging_kubernetes_pod_routing(
metadata: object,
) -> "StandardLoggingKubernetesPodRouting | None":
if not isinstance(metadata, Mapping):
return None
metadata_mapping: Final = cast( # cast-ok: routing metadata is a string-keyed mapping
Mapping[str, object], metadata
)
routing: Final = metadata_mapping.get(KUBERNETES_POD_ROUTING_KEY)
if not isinstance(routing, Mapping):
return None
routing_mapping: Final = cast( # cast-ok: pod routing records use string keys
Mapping[str, object], routing
)
service_host: Final = routing_mapping.get("service_host")
pod_ip: Final = routing_mapping.get("pod_ip")
pod_count: Final = routing_mapping.get("pod_count")
selection: Final = routing_mapping.get("selection")
if (
not isinstance(service_host, str)
or not isinstance(pod_ip, str)
or not isinstance(pod_count, int)
or isinstance(pod_count, bool)
or pod_count < 1
):
return None
if selection in ("round_robin", "session_affinity", "session_affinity_retry"):
routing_record: Final[StandardLoggingKubernetesPodRouting] = {
"service_host": service_host,
"pod_ip": pod_ip,
"pod_count": pod_count,
"selection": selection,
}
return routing_record
return None
def get_litellm_metadata_from_kwargs(kwargs: dict):
"""
Helper to get litellm metadata from all litellm request kwargs

View file

@ -43,6 +43,7 @@ from litellm.constants import (
DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT,
EMPTY_MAPPING,
INTERNAL_CALLBACK_NAMES,
KUBERNETES_POD_ROUTING_KEY,
PROVIDER_REQUEST_ID_HEADERS,
REDACTED_BY_LITELLM,
)
@ -75,6 +76,7 @@ from litellm.litellm_core_utils.classifier_logging import (
from litellm.litellm_core_utils.core_helpers import (
get_provider_response_headers_from_hidden_params,
is_expected_client_error,
proxy_stamped_kubernetes_pod_routing,
proxy_stamped_used_client_oauth_token,
reconstruct_model_name,
set_response_cost_in_hidden_params,
@ -149,6 +151,7 @@ from litellm.types.llms.openai import (
from litellm.types.mcp import MCPPostCallResponseObject
from litellm.types.prompts.init_prompts import PromptSpec
from litellm.types.rerank import RerankResponse
from litellm.types.router import RouterRateLimitError
from litellm.types.utils import (
DEPLOYMENT_SCOPED_PRICING_FIELDS,
CachingDetails,
@ -298,7 +301,9 @@ else:
_GENERIC_API_LOGGER_CLS: Final = GenericAPILogger
_in_memory_loggers: Final[list[CustomLogger]] = []
_STANDARD_LOGGING_METADATA_RESOLVED_KEYS: Final[frozenset[str]] = frozenset(("used_client_oauth_token", "usage_object"))
_STANDARD_LOGGING_METADATA_RESOLVED_KEYS: Final[frozenset[str]] = frozenset(
("used_client_oauth_token", "usage_object", KUBERNETES_POD_ROUTING_KEY)
)
_STANDARD_LOGGING_METADATA_KEYS: Final[frozenset[str]] = (
frozenset(StandardLoggingMetadata.__annotations__.keys()) - _STANDARD_LOGGING_METADATA_RESOLVED_KEYS
)
@ -6275,7 +6280,7 @@ class StandardLoggingPayloadSetup:
@staticmethod
def get_standard_logging_metadata(
metadata: Mapping[str, object] | None,
litellm_params: dict | None = None,
litellm_params: dict[str, object] | None = None,
prompt_integration: str | None = None,
applied_guardrails: list[str] | None = None,
mcp_tool_call_metadata: StandardLoggingMCPToolCall | None = None,
@ -6315,6 +6320,8 @@ class StandardLoggingPayloadSetup:
prompt_integration=prompt_integration,
)
kubernetes_pod_routing: Final = proxy_stamped_kubernetes_pod_routing(metadata, litellm_params)
# Initialize with default values
clean_metadata = StandardLoggingMetadata(
user_api_key_hash=None,
@ -6351,6 +6358,7 @@ class StandardLoggingPayloadSetup:
user_api_key_auth_metadata=None,
team_alias=None,
team_id=None,
kubernetes_pod_routing=kubernetes_pod_routing,
used_client_oauth_token=resolve_used_client_oauth_token(
proxy_stamped_used_client_oauth_token(metadata, litellm_params),
custom_llm_provider,
@ -6685,6 +6693,7 @@ class StandardLoggingPayloadSetup:
rate_limit_category: Final = validate_rate_limit_category(getattr(original_exception, "category", None))
rate_limit_type: Final = validate_rate_limit_type(getattr(original_exception, "rate_limit_type", None))
budget_error: Final = original_exception if isinstance(original_exception, BudgetExceededError) else None
router_error: Final = original_exception if isinstance(original_exception, RouterRateLimitError) else None
provider_request_id: Final = _get_provider_request_id(original_exception) if original_exception else None
return StandardLoggingPayloadErrorInformation(
@ -6700,6 +6709,7 @@ class StandardLoggingPayloadSetup:
error_budget_entity_id=budget_error.entity_id if budget_error else None,
error_budget_limit=budget_error.max_budget if budget_error else None,
error_budget_spend=budget_error.current_cost if budget_error else None,
error_cooldown_deployments=router_error.cooldown_deployments if router_error else None,
normalized_error=normalize_error(original_exception, error_status, error_message),
)
@ -7418,6 +7428,7 @@ def get_standard_logging_metadata(
user_api_key_auth_metadata=None,
team_alias=None,
team_id=None,
kubernetes_pod_routing=None,
used_client_oauth_token=None,
)
if isinstance(metadata, dict):
@ -7491,6 +7502,7 @@ def create_dummy_standard_logging_payload() -> StandardLoggingPayload:
requester_ip_address="127.0.0.1",
requester_metadata=None,
user_api_key_end_user_id="test_end_user",
kubernetes_pod_routing=None,
)
hidden_params: Final = StandardLoggingHiddenParams(

View file

@ -0,0 +1,37 @@
import json
import re
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Final
@dataclass(frozen=True, slots=True)
class ChatTemplateToolSection:
preamble_tokens: int
implicit_system_turn_tokens: int
render_tool_schemas: Callable[[Sequence[Mapping[str, object]]], str]
def _deepseek_v4_tool_schemas(functions: Sequence[Mapping[str, object]]) -> str:
return "\n".join(json.dumps(function, ensure_ascii=False) for function in functions)
def _qwen3_5_tool_schemas(functions: Sequence[Mapping[str, object]]) -> str:
return "".join(
"\n" + json.dumps({"type": "function", "function": function}, ensure_ascii=False) for function in functions
)
_TOOL_SECTIONS: Final = (
(
re.compile(r"deepseek-v4(?![.\d]|p\d)", re.IGNORECASE),
ChatTemplateToolSection(222, 0, _deepseek_v4_tool_schemas),
),
(re.compile(r"qwen3[._]?5(?!\d)", re.IGNORECASE), ChatTemplateToolSection(196, 5, _qwen3_5_tool_schemas)),
)
def chat_template_tool_section(model: str | None) -> ChatTemplateToolSection | None:
if not model:
return None
return next((section for pattern, section in _TOOL_SECTIONS if pattern.search(model)), None)

View file

@ -35,6 +35,10 @@ from litellm.constants import (
TOKEN_COUNTER_MAX_EXACT_CHARS,
)
from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.prompt_templates.chat_template_tools import (
ChatTemplateToolSection,
chat_template_tool_section,
)
from litellm.litellm_core_utils.tokenizer import HuggingFaceTokenizer, OpenAIEncoding
if TYPE_CHECKING:
@ -347,6 +351,9 @@ EXTRAPOLATION_SAMPLES: Final = 16
T_ParamSpec: Final = ParamSpec("T_ParamSpec")
T_Retval = TypeVar("T_Retval")
_COUNT_OFFLOAD_LIMITER: Final = anyio.lowlevel.RunVar[anyio.CapacityLimiter]("litellm_count_offload_limiter")
_ANTHROPIC_TOOL_KEYS_MAPPED_BY_BRIDGE: Final = frozenset(
("name", "input_schema", "description", "cache_control", "strict", "type", "eager_input_streaming")
)
def _count_offload_limiter_for_this_loop() -> anyio.CapacityLimiter:
@ -404,6 +411,7 @@ class _MessageCountParams:
self.tokens_per_message = 3
self.tokens_per_name = 1
self.count_function = _get_count_function(model, custom_tokenizer)
self.tool_section = chat_template_tool_section(model)
def token_counter(
@ -467,7 +475,9 @@ def token_counter(
num_tokens = _count_messages(params, new_messages, use_default_image_token_count, default_token_count)
if count_response_tokens is False:
includes_system_message: Final = any(message.get("role", None) == "system" for message in new_messages)
num_tokens += _count_extra(params.count_function, tools, tool_choice, includes_system_message)
num_tokens += _count_extra(
params.count_function, tools, tool_choice, includes_system_message, params.tool_section
)
else:
raise ValueError("Either text or messages must be provided")
@ -501,7 +511,8 @@ def messages_reach_token_count(
_count_messages(params, [message], use_default_image_token_count, None) for message in new_messages
)
running_totals: Final = accumulate(
per_message_counts, initial=_count_extra(params.count_function, tools, None, includes_system_message)
per_message_counts,
initial=_count_extra(params.count_function, tools, None, includes_system_message, params.tool_section),
)
return any(total >= threshold for total in running_totals)
@ -592,6 +603,7 @@ def _count_extra(
tools: list[ChatCompletionToolParam] | None,
tool_choice: ChatCompletionNamedToolChoiceParam | None,
includes_system_message: bool,
tool_section: ChatTemplateToolSection | None,
) -> int:
"""Count extra tokens for function definitions and tool choices.
Args:
@ -599,26 +611,56 @@ def _count_extra(
tools (Optional[List[ChatCompletionToolParam]]): The available tools.
tool_choice (Optional[ChatCompletionNamedToolChoiceParam]): The tool choice.
includes_system_message (bool): Whether the messages include a system message.
tool_section (Optional[ChatTemplateToolSection]): How the model's chat template renders tools, if known.
"""
reply_primer_tokens: Final = 3
tool_choice_tokens: Final = 0 if tool_section is not None else _count_tool_choice(count_function, tool_choice)
return (
reply_primer_tokens
+ _count_tool_definitions(count_function, tools, includes_system_message, tool_section)
+ tool_choice_tokens
)
num_tokens = 3 # every reply is primed with <|start|>assistant<|message|>
if tools:
num_tokens += count_function(_format_function_definitions(tools))
num_tokens += 9 # Additional tokens for function definition of tools
# If there's a system message and tools are present, subtract four tokens
if tools and includes_system_message:
num_tokens -= 4
# If tool_choice is 'none', add one token.
# If it's an object, add 4 + the number of tokens in the function name.
# If it's undefined or 'auto', don't add anything.
def _count_tool_definitions(
count_function: TokenCounterFunction,
tools: Sequence[object] | None,
includes_system_message: bool,
tool_section: ChatTemplateToolSection | None,
) -> int:
if not tools:
return 0
if tool_section is not None:
return _count_chat_template_tools(count_function, tools, includes_system_message, tool_section)
system_message_discount: Final = 4 if includes_system_message else 0
return count_function(_format_function_definitions(tools)) + 9 - system_message_discount
def _count_tool_choice(
count_function: TokenCounterFunction,
tool_choice: ChatCompletionNamedToolChoiceParam | None,
) -> int:
if tool_choice == "none":
num_tokens += 1
elif isinstance(tool_choice, dict):
num_tokens += 7
num_tokens += count_function(str(tool_choice["function"]["name"]))
return 1
if isinstance(tool_choice, dict):
return 7 + count_function(str(tool_choice["function"]["name"]))
return 0
return num_tokens
def _count_chat_template_tools(
count_function: TokenCounterFunction,
tools: Sequence[object],
includes_system_message: bool,
tool_section: ChatTemplateToolSection,
) -> int:
functions: Final = tuple(
function for function in _function_definitions(_tools_sent_as_functions(tools)) if function.get("name")
)
if not functions:
return 0
system_turn_tokens: Final = 0 if includes_system_message else tool_section.implicit_system_turn_tokens
schemas: Final = tool_section.render_tool_schemas(functions)
return tool_section.preamble_tokens + system_turn_tokens + count_function(schemas)
def _get_extrapolating_count_function(
@ -1034,15 +1076,31 @@ def _format_function_definitions(tools: Sequence[object]) -> str:
lines: Final = []
lines.append("namespace functions {")
lines.append("")
for tool in tools:
if not isinstance(tool, Mapping):
continue
for function in _function_definitions_for_tool(cast(Mapping[str, object], tool)):
lines.extend(_format_single_function_definition(function))
for function in _function_definitions(tools):
lines.extend(_format_single_function_definition(function))
lines.append("} // namespace functions")
return "\n".join(lines)
def _tools_sent_as_functions(tools: Sequence[object]) -> tuple[Mapping[str, object], ...]:
return tuple(tool for tool in _tool_mappings(tools) if not _is_anthropic_web_search_tool(tool))
def _is_anthropic_web_search_tool(tool: Mapping[str, object]) -> bool:
return tool.get("name") == "web_search" and "input_schema" not in tool and tool.get("type") != "function"
def _tool_mappings(tools: Sequence[object]) -> Iterable[Mapping[str, object]]:
for tool in tools:
if isinstance(tool, Mapping):
yield cast(Mapping[str, object], tool)
def _function_definitions(tools: Sequence[object]) -> Iterable[Mapping[str, object]]:
for tool in _tool_mappings(tools):
yield from _function_definitions_for_tool(tool)
def _function_definitions_for_tool(tool: Mapping[str, object]) -> Iterable[Mapping[str, object]]:
function: Final = tool.get("function")
if isinstance(function, Mapping):
@ -1054,12 +1112,21 @@ def _function_definitions_for_tool(tool: Mapping[str, object]) -> Iterable[Mappi
if isinstance(declaration, Mapping):
yield declaration
return
parameters: Final = tool.get("input_schema") or tool.get("parameters") or {}
input_schema: Final = tool.get("input_schema")
parameters: Final = input_schema or tool.get("parameters") or {}
normalized_parameters: Final = parameters if isinstance(parameters, Mapping) else {}
unmapped_anthropic_keys: Final = (
{key: value for key, value in tool.items() if key not in _ANTHROPIC_TOOL_KEYS_MAPPED_BY_BRIDGE}
if input_schema is not None
else {}
)
description: Final = tool.get("description")
strict: Final = tool.get("strict")
yield {
"name": tool.get("name"),
"description": tool.get("description"),
"parameters": normalized_parameters,
"parameters": {**normalized_parameters, **unmapped_anthropic_keys},
**({"description": description} if description is not None else {}),
**({"strict": bool(strict)} if strict is not None else {}),
}

View file

@ -1,12 +1,10 @@
"""Support for Azure OpenAI gpt-5 model family."""
from typing import Final
from typing import ClassVar, Final
import litellm
from litellm.exceptions import UnsupportedParamsError
from litellm.llms.openai.chat.gpt_5_transformation import (
OpenAIGPT5Config,
get_effort_level,
is_gpt_reasoning_series_name,
)
from litellm.types.llms.openai import AllMessageValues
@ -15,9 +13,16 @@ from .gpt_transformation import AzureOpenAIConfig
class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
"""Azure specific handling for gpt-5 models."""
"""Azure specific handling for gpt-5 models.
reasoning_effort none joins xhigh as opt-in here: Azure rejected it on every gpt-5
deployment before gpt-5.1 (https://github.com/BerriAI/litellm/issues/16704), and a deployment
name the map never heard of must not forward it. See
https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/reasoning
"""
GPT5_SERIES_ROUTE = "gpt5_series/"
OPT_IN_REASONING_EFFORTS: ClassVar[frozenset[str]] = frozenset(("xhigh", "none"))
@classmethod
def _model_map_lookup_name(cls, model: str) -> str:
@ -27,11 +32,18 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
model_prices_and_context_window.json; ``azure/gpt-5.1`` is. Overriding the shared
resolver rather than one lookup means the supports, explicitly-disabled and
default-effort answers all read the same entry.
An ``azure_ai/`` name reaches this config when ``get_llm_provider`` re-routes a Foundry
deployment on an OpenAI-v1 host to the azure provider with the prefix intact. It is the
same product the azure_ai config reads ``azure_ai/<model>`` for, so that row answers when
the map carries it, and the azure/ twin when it does not.
"""
if model.startswith(cls.GPT5_SERIES_ROUTE):
return "azure/" + model[len(cls.GPT5_SERIES_ROUTE) :]
if model.startswith("azure/"):
return model
if model.startswith("azure_ai/"):
return model if model in litellm.model_cost else "azure/" + model.removeprefix("azure_ai/")
return "azure/" + model
@classmethod
@ -75,33 +87,7 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
drop_params: bool,
api_version: str = "",
) -> dict:
reasoning_effort_value = non_default_params.get("reasoning_effort") or optional_params.get("reasoning_effort")
effective_effort: Final = get_effort_level(reasoning_effort_value)
# gpt-5.1/5.2/5.4 support reasoning_effort='none', but other gpt-5 models don't
# See: https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/reasoning
supports_none: Final = self._supports_reasoning_effort_level(model, "none")
if effective_effort == "none" and not supports_none:
if litellm.drop_params is True or (drop_params is not None and drop_params is True):
non_default_params = non_default_params.copy()
optional_params = optional_params.copy()
if get_effort_level(non_default_params.get("reasoning_effort")) == "none":
non_default_params.pop("reasoning_effort")
if get_effort_level(optional_params.get("reasoning_effort")) == "none":
optional_params.pop("reasoning_effort")
else:
raise UnsupportedParamsError(
status_code=400,
message=(
"Azure OpenAI does not support reasoning_effort='none' for this model. "
"To drop this parameter, set `litellm.drop_params=True` or for proxy:\n\n"
"`litellm_settings:\n drop_params: true`\n"
"Issue: https://github.com/BerriAI/litellm/issues/16704"
),
)
result: Final = OpenAIGPT5Config.map_openai_params(
return OpenAIGPT5Config.map_openai_params(
self,
non_default_params=non_default_params,
optional_params=optional_params,
@ -109,17 +95,6 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
drop_params=drop_params,
)
# Only drop reasoning_effort='none' for models that don't support it
result_effort: Final = get_effort_level(result.get("reasoning_effort"))
if result_effort == "none" and not supports_none:
result.pop("reasoning_effort")
# Azure gpt-5.4+ with tools + reasoning_effort is now routed to the
# Responses API bridge (same as OpenAI), so we no longer need to drop
# reasoning_effort here. See: responses_api_bridge_check() in main.py.
return result
def transform_request(
self,
model: str,

View file

@ -65,10 +65,18 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.AZURE
@staticmethod
def _is_gpt_5_model(model: str) -> bool:
return AzureOpenAIGPT5Config.is_model_gpt_5_model(model)
@staticmethod
def _supports_reasoning_effort_none(model: str) -> bool:
return AzureOpenAIGPT5Config.supports_reasoning_effort_level(model, "none")
@staticmethod
def _effort_level_is_disabled(model: str, level: str) -> bool:
return AzureOpenAIGPT5Config.row_disables_reasoning_effort_level(model, level)
@staticmethod
def _effort_resolves_to_none(model: str, effort: str | None) -> bool:
return AzureOpenAIGPT5Config.effort_resolves_to_none(model, effort)

View file

@ -3,6 +3,7 @@ from typing import Final
import httpx
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
from litellm.llms.azure_ai.chat.transformation import AzureAIGPT5Config
from litellm.llms.azure_ai.common_utils import (
AzureFoundryModelInfo,
api_key_header_for_base,
@ -27,6 +28,18 @@ class AzureAIResponsesAPIConfig(AzureOpenAIResponsesAPIConfig):
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.AZURE_AI
@staticmethod
def _supports_reasoning_effort_none(model: str) -> bool:
return AzureAIGPT5Config.supports_reasoning_effort_level(model, "none")
@staticmethod
def _effort_level_is_disabled(model: str, level: str) -> bool:
return AzureAIGPT5Config.row_disables_reasoning_effort_level(model, level)
@staticmethod
def _effort_resolves_to_none(model: str, effort: str | None) -> bool:
return AzureAIGPT5Config.effort_resolves_to_none(model, effort)
def validate_environment(self, headers: dict, model: str, litellm_params: GenericLiteLLMParams | None) -> dict:
params: Final = litellm_params or GenericLiteLLMParams()
auth_headers: Final = get_azure_ai_auth_headers(

View file

@ -1,6 +1,10 @@
from typing import Final
import litellm
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
from litellm.secret_managers.main import get_secret_str
BASETEN_MODEL_API_BASE: Final = "https://inference.baseten.co/v1"
class BasetenConfig(OpenAIGPTConfig):
@ -89,11 +93,39 @@ class BasetenConfig(OpenAIGPTConfig):
Get the OpenAI compatible provider info for Baseten
"""
# Default to Model API
default_api_base: Final = "https://inference.baseten.co/v1"
default_api_base: Final = BASETEN_MODEL_API_BASE
default_api_key: Final = api_key or "BASETEN_API_KEY"
return default_api_base, default_api_key
@staticmethod
def get_api_key(api_key: str | None = None) -> str | None:
return api_key or get_secret_str("BASETEN_API_KEY")
@staticmethod
def get_api_base(api_base: str | None = None) -> str | None:
return api_base or get_secret_str("BASETEN_API_BASE") or BASETEN_MODEL_API_BASE
def get_models(self, api_key: str | None = None, api_base: str | None = None) -> list[str]:
"""
Calls Baseten's Model API `/v1/models` endpoint and returns the live catalog.
"""
resolved_api_key: Final = self.get_api_key(api_key)
if resolved_api_key is None:
raise ValueError(
"BASETEN_API_KEY is not set. Please set the environment variable, to query Baseten's `/models` endpoint."
)
resolved_api_base: Final = (self.get_api_base(api_base) or BASETEN_MODEL_API_BASE).rstrip("/")
response: Final = litellm.module_level_client.get( # pyright: ignore[reportUnknownMemberType] # untyped params
url=f"{resolved_api_base.removesuffix('/v1')}/v1/models",
headers={"Authorization": f"Bearer {resolved_api_key}"},
)
if response.status_code != 200:
raise ValueError(
f"Failed to fetch models from Baseten. Status code: {response.status_code}, Response: {response.text}"
)
return ["baseten/" + model["id"] for model in response.json()["data"]]
@staticmethod
def is_dedicated_deployment(model: str) -> bool:
"""
@ -118,4 +150,4 @@ class BasetenConfig(OpenAIGPTConfig):
return f"https://model-{model_id}.api.baseten.co/environments/production/sync/v1"
else:
# Use Model API
return "https://inference.baseten.co/v1"
return BASETEN_MODEL_API_BASE

View file

@ -0,0 +1,7 @@
"""
DeepSeek Search API module.
"""
from litellm.llms.deepseek.search.transformation import DeepSeekSearchConfig
__all__ = ["DeepSeekSearchConfig"]

View file

@ -0,0 +1,172 @@
"""
DeepSeek Search API.
Endpoint: POST https://api.deepseek.com/v1/tools/search
Docs: https://api-docs.deepseek.com/guides/search/
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Final
import httpx
from pydantic import ConfigDict, TypeAdapter, ValidationError
from litellm.exceptions import BadRequestError
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True))
_JSON_OBJECTS: Final = TypeAdapter(Sequence[Mapping[str, object]], config=ConfigDict(hide_input_in_errors=True))
_STR_LIST: Final = TypeAdapter(list[str])
DEEPSEEK_SEARCH_MAX_COUNT: Final = 50
DEEPSEEK_SEARCH_MAX_QUERY_CHARS: Final = 128
_DEEPSEEK_SEARCH_PATH: Final = "/v1/tools/search"
# DeepSeek chat defaults DEEPSEEK_API_BASE to the /beta prefix; the search route lives at the root.
_CHAT_ONLY_BASE_SUFFIXES: Final = ("/beta", "/v1")
class DeepSeekSearchConfig(BaseSearchConfig):
DEEPSEEK_API_BASE = "https://api.deepseek.com"
@staticmethod
def ui_friendly_name() -> str:
return "DeepSeek"
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: BaseSearchConfig.validate_environment signature
api_key: str | None = None,
api_base: str | None = None,
**kwargs: object, # kwargs-ok: BaseSearchConfig.validate_environment signature
) -> dict[str, str]: # mutable-ok: the http handler passes this straight to httpx as headers
resolved_key: Final = self.resolve_server_api_key(
caller_api_key=api_key,
caller_api_base=api_base,
key_env_vars=("DEEPSEEK_API_KEY",),
base_env_var="DEEPSEEK_API_BASE",
default_api_base=self.DEEPSEEK_API_BASE,
)
if not resolved_key:
raise ValueError("DEEPSEEK_API_KEY is not set. Set `DEEPSEEK_API_KEY` environment variable.")
return {**headers, "Authorization": f"Bearer {resolved_key}", "Content-Type": "application/json"}
def get_complete_url(
self,
api_base: str | None,
optional_params: dict[str, object], # mutable-ok: BaseSearchConfig.get_complete_url signature
data: dict[str, object] | list[dict[str, object]] | None = None, # mutable-ok: base signature
**kwargs: object, # kwargs-ok: BaseSearchConfig.get_complete_url signature
) -> str:
resolved_base: Final = api_base or get_secret_str("DEEPSEEK_API_BASE") or self.DEEPSEEK_API_BASE
clean_base: Final = resolved_base.rstrip("/")
if clean_base.endswith(_DEEPSEEK_SEARCH_PATH):
return clean_base
return f"{_strip_chat_suffix(clean_base)}{_DEEPSEEK_SEARCH_PATH}"
def transform_search_request(
self,
query: str | list[str], # mutable-ok: BaseSearchConfig.transform_search_request signature
optional_params: dict[str, object], # mutable-ok: base signature
**kwargs: object, # kwargs-ok: BaseSearchConfig.transform_search_request signature
) -> dict[str, object]: # mutable-ok: the http handler sends this as the JSON body
"""
Unified params -> DeepSeek body: ``query`` (list joined with spaces, ``search_domain_filter``
folded in as ``site:`` operators, the whole string capped at 128 characters by DeepSeek),
``max_results`` -> ``count`` clamped to [1, 50].
``country`` and ``max_tokens_per_page`` have no DeepSeek equivalent and are dropped.
Everything outside the unified spec (``need_content``, ``ori_question``, ``localization``)
is forwarded as-is.
"""
joined_query: Final = " ".join(query) if isinstance(query, list) else query
domains: Final = _domain_filter(optional_params.get("search_domain_filter"))
count: Final = _count(optional_params.get("max_results"))
unified: Final = self.get_supported_perplexity_optional_params()
passthrough: Final = {k: v for k, v in optional_params.items() if k not in unified}
return {
**passthrough,
"query": _checked_query(_with_site_filters(joined_query, domains), domains),
**({"count": count} if count is not None else {}),
}
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs: object, # kwargs-ok: BaseSearchConfig.transform_search_response signature
) -> SearchResponse:
response_json: Final = _JSON_OBJECT.validate_python(raw_response.json())
raw_results: Final = _JSON_OBJECTS.validate_python(response_json.get("results", ()))
return SearchResponse(
results=[_to_search_result(item) for item in raw_results],
object="search",
)
def _strip_chat_suffix(base: str) -> str:
return next(
(base.removesuffix(suffix) for suffix in _CHAT_ONLY_BASE_SUFFIXES if base.endswith(suffix)),
base,
)
def _domain_filter(raw: object) -> tuple[str, ...]:
try:
return tuple(_STR_LIST.validate_python(raw))
except ValidationError:
return ()
def _with_site_filters(query: str, domains: tuple[str, ...]) -> str:
if not domains:
return query
site_clause: Final = " OR ".join(f"site:{domain}" for domain in domains)
return f"{query} {site_clause}"
def _checked_query(query: str, domains: tuple[str, ...]) -> str:
if len(query) <= DEEPSEEK_SEARCH_MAX_QUERY_CHARS:
return query
filter_note: Final = (
f" ({len(domains)} search_domain_filter entries are sent as site: operators inside the query)"
if domains
else ""
)
raise BadRequestError(
message=(
f"DeepSeek Search accepts a query of at most {DEEPSEEK_SEARCH_MAX_QUERY_CHARS} characters, "
f"got {len(query)}{filter_note}. Shorten the query or the domain filter."
),
model="deepseek/search",
llm_provider="deepseek",
)
def _count(raw: object) -> int | None:
if isinstance(raw, bool) or not isinstance(raw, int):
return None
return max(1, min(raw, DEEPSEEK_SEARCH_MAX_COUNT))
def _to_search_result(item: Mapping[str, object]) -> SearchResult:
date: Final = item.get("date")
return SearchResult.model_validate(
{
**item,
"title": _as_str(item.get("title")),
"url": _as_str(item.get("url")),
"snippet": _as_str(item.get("snippet")),
"date": date if isinstance(date, str) else None,
"last_updated": None,
}
)
def _as_str(value: object) -> str:
return value if isinstance(value, str) else ""

View file

@ -1,7 +1,7 @@
"""Support for OpenAI gpt-5 model family."""
import re
from typing import Final
from typing import ClassVar, Final
import litellm
from litellm.utils import (
@ -71,6 +71,10 @@ _get_effort_level = get_effort_level
GPT_REASONING_SERIES_MARKERS: Final = ("gpt-5", "gpt-6")
def unsupported_reasoning_effort_message(param: str, level: str, model: str) -> str:
return f"{param}={level} is not supported for {model}. To drop unsupported params set `litellm.drop_params = True`"
def is_gpt_reasoning_series_name(model: str) -> bool:
normalized: Final = model.split("/")[-1]
return any(marker in model for marker in GPT_REASONING_SERIES_MARKERS) and "gpt-5-chat" not in normalized
@ -86,6 +90,9 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
- Support for GPT-5-Codex models optimized for code generation.
"""
OPT_IN_REASONING_EFFORTS: ClassVar[frozenset[str]] = frozenset(("xhigh",))
ROUTE_SPECIFIC_REASONING_EFFORTS: ClassVar[frozenset[str]] = frozenset(("max",))
@classmethod
def is_model_gpt_5_model(cls, model: str) -> bool:
return is_gpt_reasoning_series_name(model)
@ -205,15 +212,10 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
return False
@classmethod
def _is_reasoning_effort_level_explicitly_disabled(cls, model: str, level: str) -> bool:
def is_reasoning_effort_level_explicitly_disabled(cls, model: str, level: str) -> bool:
"""Return True only when the model map explicitly sets the capability to False.
Unlike ``_supports_reasoning_effort_level`` (which requires an explicit True),
this method returns True only when ``supports_{level}_reasoning_effort`` is
explicitly set to ``False`` in the model map. A missing key is treated as
supported (i.e. this method returns False = not disabled).
Use this for opt-out checks where unknown models should be allowed through.
A missing key is not a disable. Unknown models pass through.
"""
return is_explicitly_disabled_factory(
model=cls._model_map_lookup_name(model),
@ -221,6 +223,39 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
key=f"supports_{level}_reasoning_effort",
)
@classmethod
def _is_reasoning_effort_level_explicitly_disabled(cls, model: str, level: str) -> bool:
return cls.is_reasoning_effort_level_explicitly_disabled(model, level)
@classmethod
def refuses_reasoning_effort_level(cls, model: str, level: str) -> bool:
"""The chat gate for any requested effort level, read off the model's map row alone.
An opt-in level (xhigh here; Azure adds none) is refused unless the row says true, since
the providers rejected it on most of the family when it arrived. Every other level is
refused only when the row says false: a model the map never heard of, or a row without the
flag, keeps forwarding what it was sent, and a level with no flag at all (medium, high) is
never refused. So a new model in the family needs its map row and no code change, the
row's supports_<level>_reasoning_effort flags being the whole contract.
A route-specific level (max) is never refused by either gate: the same model honors it
on Responses and refuses it on Chat Completions, so one row flag cannot gate it on
either route. Chat forwards it for the Responses bridge and a plain chat call gets the
provider's own 400, exactly as before the gate read every level.
"""
if level in cls.ROUTE_SPECIFIC_REASONING_EFFORTS:
return False
if level in cls.OPT_IN_REASONING_EFFORTS:
return not cls._supports_reasoning_effort_level(model, level)
return cls.is_reasoning_effort_level_explicitly_disabled(model, level)
@classmethod
def row_disables_reasoning_effort_level(cls, model: str, level: str) -> bool:
"""The Responses gate: opt-out for every level except the route-specific ones."""
if level in cls.ROUTE_SPECIFIC_REASONING_EFFORTS:
return False
return cls.is_reasoning_effort_level_explicitly_disabled(model, level)
def get_supported_openai_params(self, model: str) -> list:
if self.is_model_gpt_5_search_model(model):
return [
@ -281,12 +316,12 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
drop_params=drop_params,
)
# Get raw reasoning_effort and effective effort level for all guards.
# Use effective_effort (extracted string) for xhigh validation, "none" checks, and
# tool/sampling guards — dict inputs like {"effort": "none", "summary": "detailed"}
# must be treated as effort="none" to avoid incorrect tool-drop or sampling errors.
raw_reasoning_effort = non_default_params.get("reasoning_effort") or optional_params.get("reasoning_effort")
effective_effort: Final = get_effort_level(raw_reasoning_effort)
# Dict inputs like {"effort": "none", "summary": "detailed"} are judged by their effort
# string so the gate, the sampling guard, and the temperature guard all see one level.
raw_reasoning_effort: Final = non_default_params.get("reasoning_effort") or optional_params.get(
"reasoning_effort"
)
requested_effort: Final = get_effort_level(raw_reasoning_effort)
# Normalize dict reasoning_effort to string for Chat Completions API.
# Example: {"effort": "high", "summary": "detailed"} -> "high"
@ -298,31 +333,20 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
if "reasoning_effort" in optional_params:
optional_params["reasoning_effort"] = normalized
if effective_effort == "xhigh":
# xhigh is an opt-in capability: only allow if model explicitly supports it.
if not self._supports_reasoning_effort_level(model, effective_effort):
if litellm.drop_params or drop_params:
non_default_params.pop("reasoning_effort", None)
optional_params.pop("reasoning_effort", None)
else:
raise litellm.utils.UnsupportedParamsError(
message=(f"reasoning_effort={effective_effort} is not supported for this model."),
status_code=400,
)
elif effective_effort in ("minimal", "low"):
# minimal/low are opt-out: unknown models pass through; only block when
# the model map explicitly sets supports_{level}_reasoning_effort=false.
# Example: gpt-5.5-pro only accepts {medium, high, xhigh}, so it sets
# supports_low_reasoning_effort=false (and supports_minimal=false).
if self._is_reasoning_effort_level_explicitly_disabled(model, effective_effort):
if litellm.drop_params or drop_params:
non_default_params.pop("reasoning_effort", None)
optional_params.pop("reasoning_effort", None)
else:
raise litellm.utils.UnsupportedParamsError(
message=(f"reasoning_effort={effective_effort} is not supported for this model."),
status_code=400,
)
if requested_effort is not None and self.refuses_reasoning_effort_level(model, requested_effort):
if litellm.drop_params or drop_params:
non_default_params.pop("reasoning_effort", None)
optional_params.pop("reasoning_effort", None)
else:
raise litellm.utils.UnsupportedParamsError(
message=unsupported_reasoning_effort_message("reasoning_effort", requested_effort, model),
status_code=400,
)
# The level the provider will see: None once dropped, so the guards below judge by the model's default effort
effective_effort: Final = get_effort_level(
non_default_params.get("reasoning_effort") or optional_params.get("reasoning_effort")
)
################################################################
# max_tokens is not supported for gpt-5 models on OpenAI API

View file

@ -25,7 +25,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.llms.openai.chat.gpt_5_transformation import is_gpt_reasoning_series_name
from litellm.llms.openai.chat.gpt_5_transformation import (
is_gpt_reasoning_series_name,
unsupported_reasoning_effort_message,
)
from litellm.responses.litellm_completion_transformation.custom_tools import TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE
from litellm.responses.litellm_completion_transformation.reasoning_items import is_litellm_minted_reasoning_item
from litellm.responses.litellm_completion_transformation.tool_search import (
@ -278,6 +281,20 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
return supports_none_reasoning_effort(model=model, custom_llm_provider=None)
@staticmethod
def _effort_level_is_disabled(model: str, level: str) -> bool:
"""Whether the model's cost-map row turns this effort level off.
The same row the chat gate reads, so a level the row refuses is refused on both wires
and a new model needs its row, not code. This surface stays opt-out for every level,
xhigh included, since it never refused a level the row omits; max is the exception, a
level the row cannot gate because the same model takes it here and refuses it on chat.
Azure overrides this so a bare deployment name reads the azure/ entry.
"""
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
return OpenAIGPT5Config.row_disables_reasoning_effort_level(model, level)
@staticmethod
def _effort_resolves_to_none(model: str, effort: str | None) -> bool:
"""Whether this request's reasoning effort ends up as "none", the one condition
@ -384,7 +401,25 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
lookup_name: Final = self._model_map_lookup_name(model)
if self._is_gpt_5_model(model=lookup_name):
reasoning: Final = params.get("reasoning") or {}
effort: Final = reasoning.get("effort") if isinstance(reasoning, dict) else None
requested_effort: Final = reasoning.get("effort") if isinstance(reasoning, dict) else None
if (
isinstance(reasoning, dict)
and isinstance(requested_effort, str)
and self._effort_level_is_disabled(lookup_name, requested_effort)
):
if not (drop_params or litellm.drop_params):
raise litellm.UnsupportedParamsError(
message=unsupported_reasoning_effort_message("reasoning.effort", requested_effort, model),
status_code=400,
)
remaining: Final = reasoning.copy()
remaining.pop("effort", None)
if remaining:
params["reasoning"] = remaining
else:
params.pop("reasoning", None)
forwarded_reasoning: Final = params.get("reasoning")
effort: Final = forwarded_reasoning.get("effort") if isinstance(forwarded_reasoning, dict) else None
supports_none: Final = self._supports_reasoning_effort_none(model=lookup_name)
effort_is_none: Final = supports_none and self._effort_resolves_to_none(lookup_name, effort)

View file

@ -3895,7 +3895,7 @@
"supports_max_reasoning_effort": false,
"supports_minimal_reasoning_effort": false,
"supports_native_streaming": true,
"supports_none_reasoning_effort": true,
"supports_none_reasoning_effort": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_cache_breakpoint": true,
@ -8409,7 +8409,7 @@
"supports_max_reasoning_effort": false,
"supports_minimal_reasoning_effort": false,
"supports_native_streaming": true,
"supports_none_reasoning_effort": true,
"supports_none_reasoning_effort": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
@ -8458,7 +8458,7 @@
"supports_max_reasoning_effort": false,
"supports_minimal_reasoning_effort": false,
"supports_native_streaming": true,
"supports_none_reasoning_effort": true,
"supports_none_reasoning_effort": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
@ -9252,7 +9252,7 @@
"supports_max_reasoning_effort": false,
"supports_minimal_reasoning_effort": false,
"supports_native_streaming": true,
"supports_none_reasoning_effort": true,
"supports_none_reasoning_effort": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
@ -24366,6 +24366,11 @@
"notes": "Grounding with Bing Search (G1 SKU): $14 per 1,000 transactions. Tokens for the Foundry model deployment that runs the grounded search are billed separately on that deployment."
}
},
"deepseek/search": {
"input_cost_per_query": 0.00075,
"litellm_provider": "deepseek",
"mode": "search"
},
"tinyfish/search": {
"input_cost_per_query": 0.0,
"litellm_provider": "tinyfish",
@ -32524,6 +32529,7 @@
"github_copilot/claude-opus-4.8": {
"cache_creation_input_token_cost": 6.25e-06,
"cache_read_input_token_cost": 5e-07,
"deprecation_date": "2026-11-16",
"input_cost_per_token": 5e-06,
"litellm_provider": "github_copilot",
"max_input_tokens": 200000,
@ -33351,9 +33357,9 @@
"supports_response_schema": true,
"supports_vision": true,
"supports_reasoning": true,
"supports_none_reasoning_effort": false,
"supports_xhigh_reasoning_effort": false,
"supports_minimal_reasoning_effort": true
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"chatgpt/gpt-5.3-codex-spark": {
"litellm_provider": "chatgpt",
@ -33684,24 +33690,28 @@
"output_cost_per_token": 1.2e-06
},
"baseten/MiniMaxAI/MiniMax-M2.5": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 3e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 1.2e-06
},
"baseten/nvidia/Nemotron-120B-A12B": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 3e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 7.5e-07
},
"baseten/zai-org/GLM-5": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 9.5e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 3.15e-06
},
"baseten/zai-org/GLM-4.7": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1.2e-07,
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
@ -33719,24 +33729,28 @@
"supports_vision": false
},
"baseten/zai-org/GLM-4.6": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 2.2e-06
},
"baseten/moonshotai/Kimi-K2.5": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 3e-06
},
"baseten/moonshotai/Kimi-K2-Thinking": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 2.5e-06
},
"baseten/moonshotai/Kimi-K2-Instruct-0905": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
@ -33760,12 +33774,14 @@
"supports_vision": false
},
"baseten/deepseek-ai/DeepSeek-V3.1": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 5e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 1.5e-06
},
"baseten/deepseek-ai/DeepSeek-V3-0324": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 7.7e-07,
"litellm_provider": "baseten",
"mode": "chat",
@ -37796,9 +37812,9 @@
"supports_tool_search": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": false,
"supports_xhigh_reasoning_effort": false,
"supports_minimal_reasoning_effort": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false,
"deprecation_date": "2027-04-01"
},
"gpt-5.3-chat-latest": {
@ -69613,7 +69629,7 @@
"supports_max_reasoning_effort": false,
"supports_minimal_reasoning_effort": false,
"supports_native_streaming": true,
"supports_none_reasoning_effort": true,
"supports_none_reasoning_effort": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
@ -72125,6 +72141,7 @@
"supports_vision": true
},
"baseten/moonshotai/Kimi-K2.6": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1.6e-07,
"input_cost_per_token": 9.5e-07,
"litellm_provider": "baseten",
@ -72142,6 +72159,7 @@
"supports_vision": true
},
"baseten/moonshotai/Kimi-K2.7-Code": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1.6e-07,
"input_cost_per_token": 9.5e-07,
"litellm_provider": "baseten",
@ -72193,6 +72211,7 @@
"supports_vision": false
},
"baseten/thinkingmachines/inkling": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1.7e-07,
"input_cost_per_token": 1e-06,
"litellm_provider": "baseten",
@ -72210,6 +72229,7 @@
"supports_vision": true
},
"baseten/thinkingmachines/inkling-small": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 5e-07,
"litellm_provider": "baseten",
@ -72278,6 +72298,7 @@
"supports_vision": false
},
"baseten/deepseek-ai/DeepSeek-V4-Pro": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1.45e-07,
"input_cost_per_token": 1.74e-06,
"litellm_provider": "baseten",
@ -75951,11 +75972,11 @@
"output_cost_per_token": 1.2e-06,
"output_cost_per_token_priority": 1.5e-06,
"source": "https://api.fireworks.ai/v1/serverless/models?format=nested",
"supports_function_calling": false,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": false,
"supports_tool_choice": true,
"supports_vision": true
},
"github_copilot/claude-sonnet-4.6": {
@ -76100,5 +76121,41 @@
"supported_output_modalities": [
"image"
]
},
"fireworks_ai/accounts/fireworks/models/minimax-m3p1-flash": {
"cache_read_input_token_cost": 1.5e-08,
"cache_read_input_token_cost_priority": 2.25e-08,
"input_cost_per_token": 1.5e-07,
"input_cost_per_token_priority": 2.25e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 6e-07,
"output_cost_per_token_priority": 9e-07,
"source": "https://platform.minimax.io/docs/guides/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"meta/sam-3.1": {
"input_cost_per_image": 0.0025,
"litellm_provider": "meta",
"mode": "responses",
"source": "https://dev.meta.ai/docs/pricing-rate-limits.md",
"supported_endpoints": [
"/v1/responses"
],
"supported_modalities": [
"text",
"image",
"video"
],
"supported_output_modalities": [
"text"
]
}
}

View file

@ -105,6 +105,7 @@ from litellm.types.utils import (
ProviderField,
StandardCallbackDynamicParams,
StandardLoggingGuardrailInformation,
StandardLoggingKubernetesPodRouting,
StandardLoggingMCPToolCall,
StandardLoggingModelInformation,
StandardLoggingPayloadErrorInformation,
@ -667,6 +668,7 @@ class LiteLLMRoutes(enum.Enum):
"/model/update",
"/model/delete",
"/model/info",
"/model/{model_id}/kubernetes_pods",
"/jwt/key/mapping/new",
"/jwt/key/mapping/update",
"/jwt/key/mapping/delete",
@ -2791,6 +2793,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
None,
description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools', 'config_overrides'. If not set, all objects are loaded (default behavior).",
)
self_serve_budget_policy: Literal["disabled", "lower_only", "ceiling"] | None = Field(
None,
description="Lets a non-admin change max_budget or budget_limits on a key they created and still own via /key/update. 'disabled' (default) requires an admin for any budget change. 'lower_only' lets the owner add or lower limits. 'ceiling' also lets the owner raise or remove limits when they could create that key with the new budget via /key/generate.",
)
user_mcp_management_mode: UserMCPManagementMode | None = Field(
None,
description="Controls how non-admin users interact with MCP servers in the dashboard. 'restricted' shows only accessible servers, 'view_all' lists every server in read-only mode.",
@ -3682,6 +3688,7 @@ class SpendLogsMetadata(TypedDict):
mcp_tool_call_metadata: StandardLoggingMCPToolCall | None
vector_store_request_metadata: list[StandardLoggingVectorStoreRequest] | None
routing_decision: StandardLoggingRoutingDecision | None
kubernetes_pod_routing: ReadOnly[StandardLoggingKubernetesPodRouting | None]
internal_call_origin: InternalCallOrigin | None
litellm_roi_estimator: ReadOnly[NotRequired[bool | None]]
guardrail_information: list[StandardLoggingGuardrailInformation] | None

View file

@ -1672,11 +1672,9 @@ class LiteLLMProxyRequestSetup:
metadata_from_headers["agent_id"] = agent_id_from_header
verbose_proxy_logger.debug("Extracted agent_id from header: %s", agent_id_from_header)
spend_log_session_id: Final = (
caller_session_beside_trace: Final = (
_caller_session_beside_trace_header(normalized_headers, request_body, _metadata_variable_name)
if litellm.request_correlation_in_logs
and chain_id is not None
and chain_id == normalized_headers.get("x-litellm-trace-id")
if chain_id is not None and chain_id == normalized_headers.get("x-litellm-trace-id")
else None
)
if chain_id:
@ -1738,8 +1736,8 @@ class LiteLLMProxyRequestSetup:
if isinstance(data[_metadata_variable_name], dict):
data[_metadata_variable_name].update(
{**metadata_from_headers, CALLER_SESSION_ID_METADATA_KEY: spend_log_session_id}
if spend_log_session_id
{**metadata_from_headers, CALLER_SESSION_ID_METADATA_KEY: caller_session_beside_trace}
if caller_session_beside_trace
else metadata_from_headers
)
return data

View file

@ -0,0 +1,135 @@
"""Classifies a /key/update request's budget edits as tightening or loosening the key's stored limits."""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from itertools import groupby
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.models.team import BudgetLimitEntry
from litellm.models.verification_token import LiteLLM_VerificationToken
from litellm.proxy._types import UpdateKeyRequest
@dataclass(frozen=True, slots=True)
class KeyBudgetUnchanged:
kind: Literal["unchanged"] = "unchanged"
@dataclass(frozen=True, slots=True)
class KeyBudgetTightened:
kind: Literal["tightened"] = "tightened"
@dataclass(frozen=True, slots=True)
class KeyBudgetLoosened:
kind: Literal["loosened"] = "loosened"
@dataclass(frozen=True, slots=True)
class KeyBudgetAdminOnly:
kind: Literal["admin_only"] = "admin_only"
KeyBudgetChange: TypeAlias = KeyBudgetUnchanged | KeyBudgetTightened | KeyBudgetLoosened | KeyBudgetAdminOnly
_FieldChange: TypeAlias = Literal["unchanged", "tightened", "loosened"]
SELF_SERVE_BUDGET_POLICY_SETTING: Final = "self_serve_budget_policy"
SelfServeBudgetPolicy: TypeAlias = Literal["disabled", "lower_only", "ceiling"]
_STORED_WINDOWS: Final = TypeAdapter(list[BudgetLimitEntry])
_POLICY: Final = TypeAdapter(SelfServeBudgetPolicy)
def resolve_self_serve_budget_policy(general_settings: Mapping[str, object]) -> SelfServeBudgetPolicy:
configured: Final = general_settings.get(SELF_SERVE_BUDGET_POLICY_SETTING, "disabled")
try:
return _POLICY.validate_python(configured)
except ValidationError:
verbose_proxy_logger.warning(
"general_settings.%s=%r is not one of disabled, lower_only, ceiling; using disabled",
SELF_SERVE_BUDGET_POLICY_SETTING,
configured,
)
return "disabled"
def _max_budget_change(data: UpdateKeyRequest, existing: LiteLLM_VerificationToken) -> _FieldChange:
if "max_budget" not in data.model_fields_set or data.max_budget == existing.max_budget:
return "unchanged"
if data.max_budget is None:
return "loosened"
if existing.max_budget is None or data.max_budget < existing.max_budget:
return "tightened"
return "loosened"
def _duration(window: BudgetLimitEntry) -> str:
return window.budget_duration
def _caps_by_duration(windows: Sequence[BudgetLimitEntry]) -> Mapping[str, float]:
grouped: Final = groupby(sorted(windows, key=_duration), key=_duration)
return MappingProxyType({duration: min(w.max_budget for w in group) for duration, group in grouped})
def _stored_caps(existing: LiteLLM_VerificationToken) -> Mapping[str, float] | None:
try:
return _caps_by_duration(_STORED_WINDOWS.validate_python(existing.budget_limits or []))
except ValidationError:
return None
def _budget_limits_change(data: UpdateKeyRequest, existing: LiteLLM_VerificationToken) -> _FieldChange:
"""
Window spend counters are keyed by the exact `budget_duration` string, so a window only
counts as kept when the same string survives with a cap no higher than before.
"""
if "budget_limits" not in data.model_fields_set:
return "unchanged"
stored: Final = _stored_caps(existing)
if stored is None:
return "loosened"
requested: Final = _caps_by_duration(data.budget_limits or ())
kept_or_lowered: Final = all(d in requested and requested[d] <= cap for d, cap in stored.items())
return "tightened" if kept_or_lowered else "loosened"
@dataclass(frozen=True, slots=True)
class EffectiveKeyBudget:
max_budget: float | None
budget_limits: tuple[BudgetLimitEntry, ...]
def stored_budget_windows(existing: LiteLLM_VerificationToken) -> tuple[BudgetLimitEntry, ...]:
"""The key's stored windows, or none when the column holds something the window model cannot read."""
try:
return tuple(_STORED_WINDOWS.validate_python(existing.budget_limits or []))
except ValidationError:
return ()
def effective_key_budget(data: UpdateKeyRequest, existing: LiteLLM_VerificationToken) -> EffectiveKeyBudget:
"""The budget the key would carry after the update, with omitted fields keeping their stored values."""
max_budget: Final = data.max_budget if "max_budget" in data.model_fields_set else existing.max_budget
if "budget_limits" in data.model_fields_set:
return EffectiveKeyBudget(max_budget=max_budget, budget_limits=tuple(data.budget_limits or ()))
return EffectiveKeyBudget(max_budget=max_budget, budget_limits=stored_budget_windows(existing))
def classify_key_budget_change(data: UpdateKeyRequest, existing: LiteLLM_VerificationToken) -> KeyBudgetChange:
overrides_linked_budget: Final = (
existing.budget_id is not None and _max_budget_change(data, existing) != "unchanged"
)
if data.spend is not None or "soft_budget" in data.model_fields_set or overrides_linked_budget:
return KeyBudgetAdminOnly()
changes: Final = frozenset((_max_budget_change(data, existing), _budget_limits_change(data, existing)))
if "loosened" in changes:
return KeyBudgetLoosened()
if "tightened" in changes:
return KeyBudgetTightened()
return KeyBudgetUnchanged()

View file

@ -106,6 +106,17 @@ from litellm.proxy.management_endpoints.common_utils import ( # noqa: F401 # l
validate_budget_duration,
validate_finite_spend,
)
from litellm.proxy.management_endpoints.key_budget_change import (
KeyBudgetAdminOnly,
KeyBudgetChange,
KeyBudgetLoosened,
KeyBudgetTightened,
KeyBudgetUnchanged,
classify_key_budget_change,
effective_key_budget,
resolve_self_serve_budget_policy,
stored_budget_windows,
)
from litellm.proxy.management_endpoints.model_management_endpoints import ( # noqa: F401 # legacy module exports
_add_model_to_db, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
add_model_to_db,
@ -190,6 +201,7 @@ from litellm.types.router import Deployment
from litellm.types.utils import (
BudgetConfig,
PersonalUIKeyGenerationConfig,
StandardKeyGenerationConfig,
TeamUIKeyGenerationConfig,
)
@ -848,6 +860,33 @@ def key_generation_check(
return _personal_key_generation_check(user_api_key_dict=user_api_key_dict, data=data)
def _caller_may_generate_key(
team_table: LiteLLM_TeamTableCachedObj | None,
user_api_key_dict: UserAPIKeyAuth,
) -> bool:
"""Non-raising mirror of the role and member-permission gates in `key_generation_check`."""
settings: Final = litellm.key_generation_settings or StandardKeyGenerationConfig()
if team_table is None:
personal: Final = settings.get("personal_key_generation")
return (
personal is None
or "allowed_user_roles" not in personal
or user_api_key_dict.user_role in personal["allowed_user_roles"]
)
team_settings: Final = settings.get(
"team_key_generation", TeamUIKeyGenerationConfig(allowed_team_member_roles=["admin", "user"])
)
caller_team_role: Final = _get_caller_team_role(team_table=team_table, user_api_key_dict=user_api_key_dict)
if caller_team_role is None or (
"allowed_team_member_roles" in team_settings
and caller_team_role not in team_settings["allowed_team_member_roles"]
):
return False
return caller_team_role == "admin" or TeamMemberPermissionChecks.is_route_granted_to_team_members(
team_table=team_table, route=KeyManagementRoutes.KEY_GENERATE.value
)
def raise_on_invalid_key_logging_config(metadata: Mapping[str, object] | None) -> None:
"""Key-level logging writes go through key metadata, not /team/callback.
@ -1072,7 +1111,7 @@ _check_permissions_caller_permission: Final = check_permissions_caller_permissio
def _check_budget_limits_delegation_ceiling(
budget_limits: list[BudgetLimitEntry] | None,
budget_limits: Sequence[BudgetLimitEntry] | None,
delegation_ceiling: float | None,
user_api_key_dict: UserAPIKeyAuth,
is_ui_session_team_key: bool,
@ -1122,6 +1161,67 @@ def _check_budget_limits_delegation_ceiling(
)
def _enforce_budget_delegation_ceiling(
requested_max_budget: float | None,
requested_team_id: str | None,
budget_limits: Sequence[BudgetLimitEntry] | None,
team_table: LiteLLM_TeamTableCachedObj | None,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
# Delegated-authority ceiling (GHSA-q775-qw9r-2r4g): a non-admin caller
# cannot grant a key a higher budget than their own authority.
# UI session personal keys are capped by user_max_budget when it is available.
is_ui_session_token: Final = user_api_key_dict.team_id == UI_SESSION_TOKEN_TEAM_ID
is_ui_session_team_key: Final = is_ui_session_token and requested_team_id is not None
if (
user_api_key_dict.is_session_token
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not is_ui_session_team_key
and requested_max_budget is not None
and team_table is None
):
raise HTTPException(
status_code=400,
detail={
"error": (
f"max_budget ({requested_max_budget}) cannot be set without "
"specifying team_id when using a CLI session token."
)
},
)
delegation_ceiling: Final = (
user_api_key_dict.user_max_budget
if is_ui_session_token and user_api_key_dict.user_max_budget is not None
else user_api_key_dict.max_budget
if user_api_key_dict.max_budget is not None
else (team_table.max_budget if user_api_key_dict.is_session_token and team_table is not None else None)
)
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not is_ui_session_team_key
and requested_max_budget is not None
and delegation_ceiling is not None
and requested_max_budget > delegation_ceiling
):
raise HTTPException(
status_code=400,
detail={
"error": (
f"max_budget ({requested_max_budget}) cannot exceed the caller's "
f"own max_budget ({delegation_ceiling})."
)
},
)
_check_budget_limits_delegation_ceiling(
budget_limits=budget_limits,
delegation_ceiling=delegation_ceiling,
user_api_key_dict=user_api_key_dict,
is_ui_session_team_key=is_ui_session_team_key,
team_table=team_table,
)
async def validate_team_id_used_in_service_account_request(
team_id: str | None,
prisma_client: PrismaClient | None,
@ -1364,57 +1464,12 @@ async def _common_key_generation_helper(
# check if user set upperbound key/generate params on config.yaml
_enforce_upperbound_key_params(data, fill_defaults=True)
# Delegated-authority ceiling (GHSA-q775-qw9r-2r4g): a non-admin caller
# cannot grant a key a higher budget than their own authority.
# UI session personal keys are capped by user_max_budget when it is available.
is_ui_session_token: Final = user_api_key_dict.team_id == UI_SESSION_TOKEN_TEAM_ID
is_ui_session_team_key = is_ui_session_token and _requested_team_id is not None
if (
user_api_key_dict.is_session_token
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not is_ui_session_team_key
and _requested_max_budget is not None
and team_table is None
):
raise HTTPException(
status_code=400,
detail={
"error": (
f"max_budget ({_requested_max_budget}) cannot be set without "
"specifying team_id when using a CLI session token."
)
},
)
delegation_ceiling: Final = (
user_api_key_dict.user_max_budget
if is_ui_session_token and user_api_key_dict.user_max_budget is not None
else user_api_key_dict.max_budget
if user_api_key_dict.max_budget is not None
else (team_table.max_budget if user_api_key_dict.is_session_token and team_table is not None else None)
)
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not is_ui_session_team_key
and _requested_max_budget is not None
and delegation_ceiling is not None
and _requested_max_budget > delegation_ceiling
):
raise HTTPException(
status_code=400,
detail={
"error": (
f"max_budget ({_requested_max_budget}) cannot exceed the caller's "
f"own max_budget ({delegation_ceiling})."
)
},
)
_check_budget_limits_delegation_ceiling(
_enforce_budget_delegation_ceiling(
requested_max_budget=_requested_max_budget,
requested_team_id=_requested_team_id,
budget_limits=data.budget_limits,
delegation_ceiling=delegation_ceiling,
user_api_key_dict=user_api_key_dict,
is_ui_session_team_key=is_ui_session_team_key,
team_table=team_table,
user_api_key_dict=user_api_key_dict,
)
check_permissions_caller_permission(
data=data,
@ -2750,16 +2805,12 @@ async def prepare_key_update_data(
non_default_values["budget_duration"] = budget_duration
if "budget_limits" in non_default_values:
raw_windows: Final = non_default_values["budget_limits"]
if raw_windows:
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
initialized_windows: Final = []
for window in raw_windows:
w = window if isinstance(window, dict) else window.model_dump()
w["reset_at"] = get_budget_reset_time(budget_duration=w["budget_duration"]).isoformat()
initialized_windows.append(w)
non_default_values["budget_limits"] = json.dumps(initialized_windows)
requested_windows: Final = _budget_limit_windows(data.budget_limits)
if requested_windows:
stored_windows: Final = stored_budget_windows(existing_key_row)
non_default_values["budget_limits"] = json.dumps(
[_window_with_reset_at(window, stored_windows) for window in requested_windows]
)
else:
# [] / None clears the field; prisma-client-py has no DbNull
# sentinel for Json? columns, so store the JSON literal null
@ -3327,6 +3378,62 @@ async def _acting_as_team_admin_for_key_update(
return True
async def _owner_may_change_key_budget(
budget_change: KeyBudgetChange,
data: UpdateKeyRequest,
existing_key_row: LiteLLM_VerificationToken,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
) -> bool:
"""
Whether the key's owner may make this budget change without admin rights, per
general_settings.self_serve_budget_policy. `ceiling` lets the owner loosen only when they
could have created the key with the new budget anyway (same generate permissions and
delegation ceiling as /key/generate). Team and org admins answer False on a loosen so they
keep the admin path, which has no ceiling.
"""
policy: Final = resolve_self_serve_budget_policy(_general_settings())
if policy == "disabled":
return False
match budget_change:
case KeyBudgetUnchanged() | KeyBudgetTightened():
return True
case KeyBudgetAdminOnly():
return False
case KeyBudgetLoosened():
if policy == "lower_only":
return False
team_id: Final = data.team_id if "team_id" in data.model_fields_set else existing_key_row.team_id
team_table: Final = (
None
if team_id is None
else await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
)
if team_table is not None and await get_team_access().allows(
user_api_key_dict, team_table, TEAM_OR_ORG_ADMIN
):
return False
if not _caller_may_generate_key(team_table=team_table, user_api_key_dict=user_api_key_dict):
return False
effective: Final = effective_key_budget(data=data, existing=existing_key_row)
upperbound: Final = litellm.upperbound_key_generate_params
if effective.max_budget is None and upperbound is not None and upperbound.max_budget is not None:
return False
_enforce_budget_delegation_ceiling(
requested_max_budget=effective.max_budget,
requested_team_id=team_id,
budget_limits=effective.budget_limits,
team_table=team_table,
user_api_key_dict=user_api_key_dict,
)
return True
async def _validate_update_key_data(
data: UpdateKeyRequest,
existing_key_row: LiteLLM_VerificationToken,
@ -3420,22 +3527,18 @@ async def _validate_update_key_data(
# - Anyone else (non-PROXY_ADMIN, not the owner, not a team member
# on a team key): must pass _check_key_admin_access (PROXY_ADMIN
# / key-owner / team-admin / org-admin of the key).
# - max_budget / spend / budget_limits: always require the admin
# check, even for the key owner or a team member (matches the
# existing admin-only budget semantics). budget_limits uses
# model_fields_set because an explicit null/[] clears the field
# and must gate the same as setting or changing it.
# - max_budget / budget_limits: admin check unless
# general_settings.self_serve_budget_policy lets the key owner make the
# change (_owner_may_change_key_budget).
# - soft_budget: admin check, since it writes the key's budget row,
# which can be a shared /budget/new tier.
# - spend gates on presence alone (not a value diff): the DB spend
# lags the live cross-pod counter, so letting an "unchanged" spend
# through the non-admin path would let a key owner / team member
# overwrite the live counter below real usage and silently weaken
# enforcement.
_is_budget_change: Final = (
(data.max_budget is not None and data.max_budget != existing_key_row.max_budget)
or data.spend is not None
or "budget_limits" in data.model_fields_set
or "soft_budget" in data.model_fields_set
)
budget_change: Final = classify_key_budget_change(data=data, existing=existing_key_row)
_is_budget_change: Final = not isinstance(budget_change, KeyBudgetUnchanged)
_existing_metadata: Final = getattr(existing_key_row, "metadata", None)
_existing_throttle: Final = (
@ -3484,8 +3587,19 @@ async def _validate_update_key_data(
# non-budget change means the caller was authorized — skip the redundant
# _check_key_admin_access that would otherwise require team/org admin status.
_key_is_team_key: Final = getattr(existing_key_row, "team_id", None) is not None
can_skip_admin_check: Final = (caller_is_creator or _key_is_team_key) and not (
_is_budget_change or is_project_change
budget_change_allowed: Final = not _is_budget_change or (
caller_is_creator
and await _owner_may_change_key_budget(
budget_change=budget_change,
data=data,
existing_key_row=existing_key_row,
user_api_key_dict=user_api_key_dict,
prisma_client=checked_prisma_client,
user_api_key_cache=user_api_key_cache,
)
)
can_skip_admin_check: Final = (
(caller_is_creator or _key_is_team_key) and budget_change_allowed and not is_project_change
)
if (not _is_proxy_admin) and not can_skip_admin_check:
hashed_key: Final = existing_key_row.token
@ -6459,6 +6573,26 @@ def _budget_limit_windows(budget_limits: Sequence[object] | str | None) -> tuple
return tuple(raw_window if isinstance(raw_window, dict) else raw_window.model_dump() for raw_window in raw_windows)
def _window_with_reset_at(
window: Mapping[str, object], stored_windows: Sequence[BudgetLimitEntry]
) -> Mapping[str, object]:
"""A resubmitted window keeps its running period, so editing one window never restarts the others."""
duration: Final = str(window["budget_duration"])
now: Final = datetime.now(timezone.utc)
open_reset_at: Final = next(
(w.reset_at for w in stored_windows if w.budget_duration == duration and _is_open_reset_at(w.reset_at, now)),
None,
)
reset_at: Final = open_reset_at if open_reset_at is not None else get_budget_reset_time(budget_duration=duration)
return {**window, "reset_at": reset_at.isoformat()}
def _is_open_reset_at(reset_at: datetime | None, now: datetime) -> bool:
if reset_at is None:
return False
return (reset_at if reset_at.tzinfo is not None else reset_at.replace(tzinfo=timezone.utc)) > now
def _advance_one_key_budget_window(window: Mapping[str, object]) -> Mapping[str, object]:
"""Restart one budget window from now, by advancing its `reset_at`.

View file

@ -168,6 +168,19 @@ if TYPE_CHECKING:
from prisma import types as prisma_types
router: Final = APIRouter()
class KubernetesPodsResponse(BaseModel):
model_config = ConfigDict(frozen=True)
model_id: str
service_host: str
port: int | None
pod_ips: tuple[str, ...]
pod_count: int
error: str | None
CLEARABLE_LITELLM_PARAMS: Final = frozenset({"cache_control_injection_points", "litellm_credential_name"})
NULL_CLEARABLE_LITELLM_PARAMS: Final = frozenset((*SPECIAL_MODEL_INFO_PARAMS, *CLEARABLE_LITELLM_PARAMS))
@ -1163,6 +1176,77 @@ def update_db_model(
return prisma_compatible_model_dict
@router.get(
"/model/{model_id}/kubernetes_pods",
response_model=KubernetesPodsResponse,
tags=["model management"],
dependencies=[Depends(user_api_key_auth)],
)
async def get_kubernetes_pods(
model_id: str,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> KubernetesPodsResponse:
if user_api_key_dict.user_role not in (
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
):
raise ProxyException(
message="Only proxy admins can view Kubernetes pods",
type=ProxyErrorTypes.auth_error.value,
code=status.HTTP_403_FORBIDDEN,
param=None,
)
from litellm.proxy.proxy_server import llm_router
if llm_router is None:
raise ProxyException(
message=CommonProxyErrors.no_llm_router.value,
type=ProxyErrorTypes.internal_server_error.value,
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
param=None,
)
deployment: Final[Mapping[str, object] | None] = _OPTIONAL_JSON_OBJECT.validate_python(
llm_router.get_model_info(id=model_id) # pyright: ignore[reportUnknownMemberType] # validated below
)
if deployment is None:
raise ProxyException(
message=f"Model with id {model_id} not found",
type=ProxyErrorTypes.not_found_error.value,
code=status.HTTP_404_NOT_FOUND,
param="model_id",
)
lookup: Final = await llm_router.kubernetes_pod_discovery.async_lookup_pods(deployment)
if lookup is None:
raw_litellm_params: Final = deployment.get("litellm_params")
litellm_params: Final[Mapping[str, object] | None] = (
_JSON_OBJECT.validate_python(raw_litellm_params) if isinstance(raw_litellm_params, Mapping) else None
)
discovery_enabled: Final = litellm_params is not None and litellm_params.get("kubernetes_pod_discovery") is True
raise ProxyException(
message=(
"Kubernetes pod discovery is enabled for this model, but its api_base is not an http:// "
"service hostname, so no pods are resolved"
if discovery_enabled
else "Kubernetes pod discovery is not enabled for this model"
),
type=ProxyErrorTypes.bad_request_error.value,
code=status.HTTP_400_BAD_REQUEST,
param="model_id",
)
return KubernetesPodsResponse(
model_id=model_id,
service_host=lookup.service_host,
port=lookup.port,
pod_ips=tuple(lookup.pod_ips),
pod_count=len(lookup.pod_ips),
error=lookup.error,
)
@router.patch(
"/model/{model_id}/update",
tags=["model management"],
@ -3257,6 +3341,10 @@ _deduplicate_litellm_router_models: Final = deduplicate_litellm_router_models
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True))
_OPTIONAL_JSON_OBJECT: Final[TypeAdapter[Mapping[str, object] | None]] = TypeAdapter(
Mapping[str, object] | None,
config=ConfigDict(hide_input_in_errors=True),
)
def model_info_as_mapping(model_info: object) -> Mapping[str, object] | None:

View file

@ -101,6 +101,13 @@ class TeamMemberPermissionChecks:
code=401,
)
@staticmethod
def is_route_granted_to_team_members(team_table: LiteLLM_TeamTableCachedObj, route: str) -> bool:
granted_routes: Final = TeamMemberPermissionChecks._get_list_of_route_enum_as_str(
TeamMemberPermissionChecks.get_permissions_for_team_member(team_table=team_table)
)
return RouteChecks.check_route_access(route=route, allowed_routes=granted_routes)
@staticmethod
def does_team_member_have_permissions_for_endpoint(
team_member_role: Literal["admin", "user"] | None,
@ -118,12 +125,10 @@ class TeamMemberPermissionChecks:
if team_member_role == "admin":
return True
_team_member_permissions: Final = TeamMemberPermissionChecks.get_permissions_for_team_member(
team_table=team_table,
)
team_member_permissions = TeamMemberPermissionChecks._get_list_of_route_enum_as_str(_team_member_permissions)
if not RouteChecks.check_route_access(route=route, allowed_routes=team_member_permissions):
if not TeamMemberPermissionChecks.is_route_granted_to_team_members(team_table=team_table, route=route):
team_member_permissions: Final = TeamMemberPermissionChecks._get_list_of_route_enum_as_str(
TeamMemberPermissionChecks.get_permissions_for_team_member(team_table=team_table)
)
raise ProxyException(
message=f"Team member does not have permissions for endpoint: {route}. You only have access to the following endpoints: {team_member_permissions} for team {team_table.team_id}. To create keys for this team, please ask your proxy admin to check the team member permission settings and update the settings to allow team member users to create keys.",
type=ProxyErrorTypes.team_member_permission_error,

View file

@ -19,6 +19,7 @@ from litellm.constants import (
CLI_SESSION_KEY_PREFIX,
EMPTY_MAPPING,
INTERNAL_CALL_ORIGIN_METADATA_KEY,
KUBERNETES_POD_ROUTING_KEY,
LITELLM_PROXY_MASTER_KEY_ALIAS,
LITELLM_TRUNCATED_PAYLOAD_FIELD,
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE,
@ -43,6 +44,7 @@ from litellm.litellm_core_utils.classifier_logging import (
)
from litellm.litellm_core_utils.core_helpers import (
get_litellm_metadata_from_kwargs,
proxy_stamped_kubernetes_pod_routing,
proxy_stamped_used_client_oauth_token,
reconstruct_model_name,
)
@ -76,6 +78,7 @@ from litellm.types.utils import (
CostBreakdown,
LlmProviders,
StandardLoggingGuardrailInformation,
StandardLoggingKubernetesPodRouting,
StandardLoggingMCPToolCall,
StandardLoggingModelInformation,
StandardLoggingPayload,
@ -178,6 +181,7 @@ _STAMPED_METADATA_KEYS: Final = frozenset(
"autorouter_savings_estimate",
"autorouter_baseline_observation",
"used_client_oauth_token",
KUBERNETES_POD_ROUTING_KEY,
"litellm_roi_estimator",
"user_api_key_user_email",
)
@ -210,6 +214,7 @@ def _get_spend_logs_metadata(
router_metadata: SpendLogsRouterMetadata | None = None,
azure_spillover: AzureSpillover | None = None,
used_client_oauth_token: bool | None = None,
kubernetes_pod_routing: StandardLoggingKubernetesPodRouting | None = None,
) -> SpendLogsMetadata:
if metadata is None:
return SpendLogsMetadata(
@ -257,6 +262,7 @@ def _get_spend_logs_metadata(
router_metadata=router_metadata,
azure_spillover=azure_spillover,
used_client_oauth_token=used_client_oauth_token,
kubernetes_pod_routing=None,
)
verbose_proxy_logger.debug("getting payload for SpendLogs, available keys in metadata: %s", [*metadata])
@ -271,6 +277,7 @@ def _get_spend_logs_metadata(
router_metadata=router_metadata,
azure_spillover=azure_spillover,
used_client_oauth_token=used_client_oauth_token,
kubernetes_pod_routing=kubernetes_pod_routing,
litellm_roi_estimator=metadata.get("litellm_roi_estimator") is True,
user_api_key_user_email=_stored_user_email(metadata),
)
@ -557,6 +564,16 @@ def get_logging_payload(
# standardize this function to be used across, s3, dynamoDB, langfuse logging
litellm_params: Final = kwargs.get("litellm_params", {})
metadata: Final = get_litellm_metadata_from_kwargs(kwargs)
routing_litellm_params: Final[Mapping[str, object] | None] = (
litellm_params if isinstance(litellm_params, Mapping) else None
)
routing_metadata: Final[object] = (
routing_litellm_params.get("metadata") if routing_litellm_params is not None else None
)
kubernetes_pod_routing: Final = proxy_stamped_kubernetes_pod_routing(
routing_metadata,
routing_litellm_params,
)
completion_start_time: Final = kwargs.get("completion_start_time", end_time)
call_type: Final = kwargs.get("call_type")
cache_hit: Final = kwargs.get("cache_hit", False)
@ -591,7 +608,9 @@ def get_logging_payload(
id = get_spend_logs_id(call_type or "acompletion", response_obj_dict, kwargs, metadata)
standard_logging_payload: Final = cast(StandardLoggingPayload | None, kwargs.get("standard_logging_object", None))
end_user_id = get_end_user_id_for_cost_tracking(litellm_params)
end_user_id = get_end_user_id_for_cost_tracking(
dict(litellm_params) if isinstance(litellm_params, Mapping) else litellm_params
)
api_key = metadata.get("user_api_key", "")
@ -769,6 +788,7 @@ def get_logging_payload(
used_client_oauth_token=resolve_used_client_oauth_token(
proxy_stamped_used_client_oauth_token(litellm_params.get("metadata"), litellm_params), custom_llm_provider
),
kubernetes_pod_routing=kubernetes_pod_routing,
azure_spillover=azure_spillover(
response_headers=kwargs.get("response_headers")
if isinstance(kwargs.get("response_headers"), Mapping)

View file

@ -5,6 +5,7 @@ from urllib.parse import urlsplit
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.llms.azure_ai.chat.transformation import AzureAIGPT5Config
from litellm.llms.azure_ai.common_utils import (
azure_ai_supports_native_responses,
foundry_chat_rejects_function_tools_while_reasoning,
@ -133,6 +134,16 @@ def chat_target_accepts_reasoning_summary(model: str, custom_llm_provider: str |
return chat_config is not None and chat_config.accepts_reasoning_summary_in_reasoning_effort
def _chat_gate_refuses_reasoning_effort_none(model: str, custom_llm_provider: str) -> bool:
match custom_llm_provider:
case "azure":
return litellm.AzureOpenAIGPT5Config.refuses_reasoning_effort_level(model, "none")
case "azure_ai":
return AzureAIGPT5Config.refuses_reasoning_effort_level(model, "none")
case _:
return OpenAIGPT5Config.refuses_reasoning_effort_level(model, "none")
def responses_api_bridge_check(
model: str,
custom_llm_provider: str,
@ -183,7 +194,9 @@ def responses_api_bridge_check(
# server-side), and Chat Completions rejects function tools whenever reasoning is
# on ("Function tools with reasoning_effort are not supported ... use
# /v1/responses or set reasoning_effort to 'none'"), so only an explicit
# ``"none"`` keeps the request chat-servable. Custom (grammar) tools are served
# ``"none"`` the chat gate forwards keeps the request chat-servable: a ``none``
# the model's map row refuses is dropped (or refused) before the call, which
# leaves the provider's default effort in force. Custom (grammar) tools are served
# natively by Chat Completions with reasoning on, so custom-only requests stay on
# chat and keep their native custom tool_call response shape.
# - The UNSET-effort arm only fires against endpoints known to enforce that
@ -213,10 +226,13 @@ def responses_api_bridge_check(
(tool.get("type") if isinstance(tool, dict) else getattr(tool, "type", None)) == "tool_search"
for tool in (tools or ())
)
if isinstance(reasoning_effort, dict):
reasoning_active = reasoning_effort.get("effort") != "none" or reasoning_effort.get("summary") is not None
else:
reasoning_active = reasoning_effort != "none"
requested_effort: Final = reasoning_effort.get("effort") if isinstance(reasoning_effort, dict) else reasoning_effort
requested_summary: Final = reasoning_effort.get("summary") if isinstance(reasoning_effort, dict) else None
reasoning_active: Final = (
requested_effort != "none"
or requested_summary is not None
or _chat_gate_refuses_reasoning_effort_none(model, custom_llm_provider)
)
# The reasoning+tools constraint is enforced by the real OpenAI backend behind any api.openai.com
# host (the default URL or a PrivateLink hostname such as <region>.privatelink.api.openai.com) and
# by Azure OpenAI through the azure provider. Resolve the effective OpenAI base arg>global>env>default

View file

@ -68,6 +68,7 @@ from litellm.constants import (
DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER,
DEFAULT_MAX_LRU_CACHE_SIZE,
INTERNAL_CALL_ORIGIN_METADATA_KEY,
KUBERNETES_POD_ROUTING_KEY,
MID_STREAM_FAILURE_LOG_WAIT_SECONDS,
OUTPUT_TOKEN_CEILING_PARAMS,
PROMPT_CACHE_PIN_CACHE_MAX_SIZE,
@ -221,6 +222,7 @@ from litellm.router_utils.cooldown_handlers import (
_first_present, # pyright: ignore[reportPrivateUsage] - shared internal helper across router_utils submodules, matching the other cooldown_handlers imports on this line
async_get_cooldown_deployments,
async_get_cooldown_deployments_with_debug_info,
get_cooldown_deployment_info,
get_cooldown_deployments,
is_advisor_orchestration_failure,
is_background_response_poll_not_found,
@ -266,6 +268,13 @@ from litellm.router_utils.handle_error import (
send_llm_exception_alert,
)
from litellm.router_utils.health_state_cache import DeploymentHealthCache
from litellm.router_utils.kubernetes_pod_discovery import (
KubernetesPodDiscovery,
async_resolve_pods_after,
async_resolve_pods_after_bound,
resolve_pods_after,
resolve_pods_after_bound,
)
from litellm.router_utils.pre_call_checks.continuation_prefill_check import (
MID_STREAM_CONTINUATION_MARKER,
ContinuationOutput,
@ -1289,6 +1298,7 @@ class Router:
cache_config: Final[dict[str, Any]] = {}
self.client_ttl = client_ttl
self.kubernetes_pod_discovery: Final = KubernetesPodDiscovery()
if redis_url is not None or (redis_host is not None and redis_port is not None):
cache_type = "redis"
@ -4598,6 +4608,7 @@ class Router:
deployment_that_just_failed: Final = _deployment_that_just_failed(
_FALLBACK_HOP_ADAPTER.validate_python(kwargs).get(metadata_variable_name)
)
routing: Final = deployment.get(KUBERNETES_POD_ROUTING_KEY)
kwargs.setdefault(metadata_variable_name, {}).update(
{
@ -4605,8 +4616,16 @@ class Router:
"model_info": model_info,
"api_base": deployment_api_base,
"deployment_model_name": deployment_model_name,
KUBERNETES_POD_ROUTING_KEY: routing,
}
)
other_bucket: Final = "metadata" if metadata_variable_name == "litellm_metadata" else "litellm_metadata"
other_bucket_metadata: Final = kwargs.get(other_bucket)
if isinstance(other_bucket_metadata, Mapping) and KUBERNETES_POD_ROUTING_KEY in other_bucket_metadata:
kwargs[other_bucket] = { # rebind-ok: this method updates the router's request kwargs in place
**other_bucket_metadata,
KUBERNETES_POD_ROUTING_KEY: routing,
}
# A retry/fallback reuses this same kwargs dict for the next deployment.
# Refund and clear any reservation the previous deployment attempt left
@ -12580,6 +12599,80 @@ class Router:
if isinstance(model_info, Mapping) and model_info.get("id") is not None
)
def _region_routable_deployments(
self,
deployments: Sequence[DeploymentTypedDict],
request_kwargs: Mapping[str, object] | None,
has_request_input: bool,
) -> tuple[Mapping[str, Any], ...]:
"""
`deployments` without the ones `_pre_call_checks` keeps this request away from for being outside its allowed
region, typed for the tag routing filter
"""
allowed_model_region: Final = None if request_kwargs is None else request_kwargs.get("allowed_model_region")
if allowed_model_region is None or not self.enable_pre_call_checks or not has_request_input:
return tuple(deployments)
if not isinstance(allowed_model_region, str):
return ()
return tuple(
deployment
for deployment in deployments
if is_region_allowed(
litellm_params=LiteLLM_Params.model_validate(deployment["litellm_params"]),
allowed_model_region=allowed_model_region,
)
)
async def _async_routable_cooled_deployment_ids(
self,
model: str,
request_deployments: Sequence[DeploymentTypedDict],
cooldown_deployments: Iterable[str],
request_kwargs: Mapping[str, object],
has_request_input: bool,
) -> frozenset[str]:
"""
Ids of the cooling deployments in `request_deployments` that region and tag routing would send this request
to, each tag routing pick counted along with the picks it falls back to once that one is unavailable, judged
without recording a tag match on the request
"""
cooled_ids: Final = frozenset(cooldown_deployments)
metadata_variable_name: Final = get_metadata_variable_name_from_kwargs(request_kwargs)
metadata: Final = request_kwargs.get(metadata_variable_name)
if metadata_variable_name in request_kwargs and not isinstance(metadata, Mapping):
return frozenset()
tag_request: Final[Mapping[str, Any]] = (
{**request_kwargs, metadata_variable_name: {**metadata}}
if isinstance(metadata, Mapping)
else request_kwargs
)
routable_ids = frozenset[str]() # rebind-ok: gains each pass's tag routing picks
remaining = tuple( # rebind-ok: each pass drops what tag routing just picked
deployment
for deployment in self._region_routable_deployments(request_deployments, request_kwargs, has_request_input)
if self._deployment_ids((deployment,)) <= cooled_ids
)
while remaining:
try:
picked = await get_deployments_for_tag(
llm_router_instance=self,
model=model,
healthy_deployments=remaining,
request_kwargs=tag_request,
metadata_variable_name=metadata_variable_name,
)
except ValueError: # tag routing sends this request to none of the rest
break
picked_identities = frozenset(id(deployment) for deployment in picked)
rest = tuple(deployment for deployment in remaining if id(deployment) not in picked_identities)
if len(rest) == len(remaining):
break
routable_ids |= self._deployment_ids(
tuple(deployment for deployment in remaining if id(deployment) in picked_identities)
)
remaining = rest
return routable_ids
def has_model_id(self, candidate_id: str) -> bool:
"""
O(1) membership check for a deployment ID without allocating large lists.
@ -13512,6 +13605,13 @@ class Router:
client = self.cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
return client
elif client_type == "async":
if (
deployment["litellm_params"].get( # pyright: ignore[reportUnknownMemberType] # legacy mapping
"kubernetes_pod_discovery"
)
is True
):
return None
if kwargs.get("stream") is True:
cache_key = f"{model_id}_stream_async_client"
client = self.cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
@ -13521,6 +13621,13 @@ class Router:
client = self.cache.get_cache(key=cache_key, local_only=True, parent_otel_span=parent_otel_span)
return client
else:
if (
deployment["litellm_params"].get( # pyright: ignore[reportUnknownMemberType] # legacy mapping
"kubernetes_pod_discovery"
)
is True
):
return None
if kwargs.get("stream") is True:
cache_key = f"{model_id}_stream_client"
client = self.cache.get_cache(key=cache_key, parent_otel_span=parent_otel_span)
@ -14425,6 +14532,13 @@ class Router:
litellm_router_instance=self,
model=model,
parent_otel_span=parent_otel_span,
route_model_ids=await self._async_routable_cooled_deployment_ids(
model=model,
request_deployments=request_deployments,
cooldown_deployments=cooldown_deployments,
request_kwargs=cast(Mapping[str, object], request_kwargs), # cast-ok: str-keyed request kwargs
has_request_input=messages is not None or input is not None,
),
)
raise exception
@ -14550,7 +14664,7 @@ class Router:
Router._pop_effort_from_nested_carrier(request_kwargs, "output_config")
Router._pop_effort_from_nested_carrier(request_kwargs, "reasoning")
async def async_get_available_deployment(
async def _async_get_available_deployment_unresolved( # noqa: ANN202 # legacy selector result remains inferred
self,
model: str,
request_kwargs: dict,
@ -14587,7 +14701,7 @@ class Router:
and self.routing_strategy != "latency-based-routing"
and self.routing_strategy != "least-busy"
): # prevent regressions for other routing strategies, that don't have async get available deployments implemented.
return self.get_available_deployment(
return self._get_available_deployment_unresolved(
model=model,
messages=messages,
input=input,
@ -14726,6 +14840,12 @@ class Router:
)
raise e
async_get_available_deployment = ( # pyright: ignore[reportUnknownVariableType] # legacy selector return
async_resolve_pods_after(
_async_get_available_deployment_unresolved # pyright: ignore[reportUnknownArgumentType] # legacy type
)
)
async def async_get_available_deployment_for_pass_through(
self,
model: str,
@ -15492,7 +15612,7 @@ class Router:
}
return cast(StandardLoggingRoutingDecision, kept) # cast-ok: dropping optional keys preserves the type
def get_available_deployment(
def _get_available_deployment_unresolved( # noqa: ANN202 # legacy selector result remains inferred
self,
model: str,
messages: list[dict[str, str]] | None = None,
@ -15618,6 +15738,17 @@ class Router:
enable_pre_call_checks=self.enable_pre_call_checks,
cooldown_list=_cooldown_list,
model_ids=model_ids,
cooldown_deployments=get_cooldown_deployment_info(
self,
self._deployment_ids(
self._region_routable_deployments(
request_deployments,
cast(Mapping[str, object] | None, request_kwargs), # cast-ok: str-keyed request kwargs
messages is not None or input is not None,
)
),
_cooldown_list,
),
)
if strategy == "simple-shuffle":
@ -15665,6 +15796,12 @@ class Router:
phase_event(_DEPLOYMENT_SELECTED_EVENT, pick_attributes)
return deployment
get_available_deployment = ( # pyright: ignore[reportUnknownVariableType] # legacy selector return
resolve_pods_after(
_get_available_deployment_unresolved # pyright: ignore[reportUnknownArgumentType] # legacy annotations
)
)
def get_available_deployment_for_pass_through(
self,
model: str,
@ -15786,6 +15923,17 @@ class Router:
enable_pre_call_checks=self.enable_pre_call_checks,
cooldown_list=_cooldown_list,
model_ids=pass_through_model_ids,
cooldown_deployments=get_cooldown_deployment_info(
self,
self._deployment_ids(
self._region_routable_deployments(
request_deployments,
cast(Mapping[str, object] | None, request_kwargs), # cast-ok: str-keyed request kwargs
messages is not None or input is not None,
)
),
_cooldown_list,
),
)
# 6. Apply load balancing strategy
@ -16086,15 +16234,22 @@ class Router:
CustomRoutingStrategy: litellm.router.CustomRoutingStrategyBase
"""
strategy: Final = CustomRoutingStrategy
setattr(
self,
"get_available_deployment",
CustomRoutingStrategy.get_available_deployment,
resolve_pods_after_bound(
strategy.get_available_deployment, # pyright: ignore[reportUnknownArgumentType] # legacy stub
self.kubernetes_pod_discovery,
),
)
setattr(
self,
"async_get_available_deployment",
CustomRoutingStrategy.async_get_available_deployment,
async_resolve_pods_after_bound(
strategy.async_get_available_deployment, # pyright: ignore[reportUnknownArgumentType] # legacy stub
self.kubernetes_pod_discovery,
),
)
def _reset_custom_routing_strategy(self) -> None:

View file

@ -8,7 +8,7 @@ Router cooldown handlers
import asyncio
import math
from collections.abc import Mapping
from collections.abc import Collection, Mapping, Sequence
from datetime import datetime
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final
@ -28,12 +28,14 @@ from litellm.constants import (
)
from litellm.exceptions import MidStreamFallbackError
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.litellm_logging import mask_api_base_credentials
from litellm.router_utils.cooldown_cache import ROUTER_COOLDOWNS_TARGET, CooldownCacheValue
from litellm.router_utils.cooldown_callbacks import router_cooldown_event_callback
from litellm.types.router import Deployment
from litellm.types.utils import (
BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN,
CooldownDeploymentInfo,
InternalCallOrigin,
)
@ -644,6 +646,26 @@ def get_cooldown_deployments(litellm_router_instance: LitellmRouter, parent_otel
_get_cooldown_deployments = get_cooldown_deployments
def _deployment_api_base(litellm_router_instance: LitellmRouter, model_id: str) -> str | None:
deployment: Final = litellm_router_instance.get_deployment(model_id=model_id)
api_base: Final = deployment.litellm_params.api_base if deployment else None
return None if api_base is None else mask_api_base_credentials(api_base)
def get_cooldown_deployment_info(
litellm_router_instance: LitellmRouter, route_model_ids: Collection[str], cooldown_list: Sequence[str]
) -> tuple[CooldownDeploymentInfo, ...]:
"""
The deployments the router resolved for this request (`route_model_ids`) that are in `cooldown_list`, each
with its masked api_base
"""
return tuple(
CooldownDeploymentInfo(model_id=model_id, api_base=_deployment_api_base(litellm_router_instance, model_id))
for model_id in cooldown_list
if model_id in route_model_ids
)
def should_cooldown_based_on_allowed_fails_policy(
litellm_router_instance: LitellmRouter,
deployment: str,

View file

@ -1,9 +1,11 @@
from collections.abc import Collection
from typing import TYPE_CHECKING, Any, Final
from litellm._logging import redact_secrets, verbose_router_logger
from litellm.constants import MAX_EXCEPTION_MESSAGE_LENGTH
from litellm.router_utils.cooldown_handlers import (
async_get_cooldown_deployments_with_debug_info,
get_cooldown_deployment_info,
)
from litellm.types.integrations.slack_alerting import AlertType
from litellm.types.router import RouterRateLimitError
@ -69,7 +71,10 @@ async def send_llm_exception_alert(
async def async_raise_no_deployment_exception(
litellm_router_instance: LitellmRouter, model: str, parent_otel_span: Span | None
litellm_router_instance: LitellmRouter,
model: str,
parent_otel_span: Span | None,
route_model_ids: Collection[str] | None = None,
):
"""
Raises a RouterRateLimitError if no deployment is found for the given model.
@ -94,4 +99,9 @@ async def async_raise_no_deployment_exception(
enable_pre_call_checks=litellm_router_instance.enable_pre_call_checks,
cooldown_list=cooldown_list_ids,
model_ids=model_ids,
cooldown_deployments=(
None
if route_model_ids is None
else get_cooldown_deployment_info(litellm_router_instance, route_model_ids, cooldown_list_ids)
),
)

View file

@ -0,0 +1,677 @@
import asyncio
import concurrent.futures
import hashlib
import inspect
import ipaddress
import socket
import threading
import time
import urllib.request
from collections.abc import Awaitable, Callable, Coroutine, Mapping, Sequence
from concurrent.futures import Future
from dataclasses import dataclass, replace
from functools import wraps
from types import MappingProxyType
from typing import (
Concatenate,
Final,
Literal,
ParamSpec,
Protocol,
TypeAlias,
TypeVar,
cast, # noqa: TID251 # typeshed lacks proxy_bypass_environment
)
import httpx
from litellm._logging import verbose_router_logger
from litellm.constants import (
CALLER_SESSION_ID_METADATA_KEY,
KUBERNETES_POD_DISCOVERY_DNS_TIMEOUT_SECONDS,
KUBERNETES_POD_DISCOVERY_IDLE_EVICTION_SECONDS,
KUBERNETES_POD_DISCOVERY_REFRESH_INTERVAL_SECONDS,
KUBERNETES_POD_ROUTING_KEY,
SESSION_ID_GENERATED_METADATA_KEY,
)
_SocketAddress: TypeAlias = tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes]
_AddressInfo: TypeAlias = tuple[socket.AddressFamily, socket.SocketKind, int, str, _SocketAddress]
_SyncLookup: TypeAlias = Future[list[_AddressInfo]]
_AsyncLookup: TypeAlias = asyncio.Future[list[_AddressInfo]]
_CacheKey: TypeAlias = tuple[str, int | None]
_PodSelection: TypeAlias = tuple[str, int, Literal["round_robin", "session_affinity", "session_affinity_retry"]]
_DeploymentT: Final = TypeVar("_DeploymentT")
@dataclass(frozen=True, slots=True)
class KubernetesPodLookup:
service_host: str
port: int | None
pod_ips: tuple[str, ...]
error: str | None
def _retry_count_from_metadata(metadata: object) -> int | None:
if not isinstance(metadata, Mapping):
return None
metadata_mapping: Final = cast( # cast-ok: request metadata is a string-keyed mapping
Mapping[str, object], metadata
)
retry_count: Final = metadata_mapping.get("request_retry_count")
if type(retry_count) is int and retry_count >= 0:
return retry_count
return None
@dataclass(frozen=True, slots=True)
class _PodSet:
ips: tuple[str, ...]
resolved_at: float
last_used_at: float
cursor: int = 0
def _lookup_outcome(
done: _SyncLookup | _AsyncLookup,
) -> Sequence[_AddressInfo] | OSError:
try:
return done.result()
except OSError as error:
return error
except BaseException as error: # noqa: BLE001 # a cancelled or crashed lookup is a failed refresh, never a crash
return OSError(str(error))
class KubernetesPodDiscovery:
def __init__(
self,
refresh_interval_seconds: float = KUBERNETES_POD_DISCOVERY_REFRESH_INTERVAL_SECONDS,
idle_eviction_seconds: float = KUBERNETES_POD_DISCOVERY_IDLE_EVICTION_SECONDS,
proxy_environment: Callable[[], Mapping[str, str]] = urllib.request.getproxies_environment,
clock: Callable[[], float] = time.monotonic,
dns_timeout_seconds: float = KUBERNETES_POD_DISCOVERY_DNS_TIMEOUT_SECONDS,
dns_executor: concurrent.futures.Executor | None = None,
) -> None:
self.refresh_interval_seconds = refresh_interval_seconds
self.idle_eviction_seconds = idle_eviction_seconds
self.dns_timeout_seconds = dns_timeout_seconds
self._dns_executor = dns_executor or concurrent.futures.ThreadPoolExecutor(
thread_name_prefix="litellm-k8s-pod-dns"
)
self.proxy_environment = proxy_environment
self.clock = clock
self._cache: Mapping[_CacheKey, _PodSet] = MappingProxyType({})
self._refreshing: frozenset[_CacheKey] = frozenset()
self._timed_out: frozenset[_CacheKey] = frozenset()
self._https_warning_hosts: frozenset[str] = frozenset()
self._proxy_warning_hosts: frozenset[str] = frozenset()
self._lock = threading.Lock()
def _begin_refresh(self, key: _CacheKey, now: float) -> tuple[_PodSet | None, bool]:
with self._lock:
self._cache = MappingProxyType(
{
cache_key: pod_set
for cache_key, pod_set in self._cache.items()
if cache_key in self._refreshing or now - pod_set.last_used_at <= self.idle_eviction_seconds
}
)
cached: Final = self._cache.get(key)
refreshing: Final = key in self._refreshing
if cached is not None and (now - cached.resolved_at < self.refresh_interval_seconds or refreshing):
return cached, False
if refreshing:
return None, False
self._refreshing = self._refreshing | {key}
return None, True
def resolve_deployment(
self,
deployment: _DeploymentT,
request_kwargs: Mapping[str, object] | None = None,
) -> _DeploymentT:
eligible: Final = self._eligible(deployment)
if eligible is None:
return deployment
url, key, deployment_mapping = eligible
host, port = key
now: Final = self.clock()
session_id: Final = self._session_id(request_kwargs)
retry_count: Final = self._request_retry_count(request_kwargs)
cached, should_refresh = self._begin_refresh(key, now)
if cached is not None:
return self._deployment_with_cached_ip(
deployment, deployment_mapping, key, url, now, session_id, retry_count
)
if not should_refresh:
return deployment
lookup: Final = self._dns_executor.submit(
socket.getaddrinfo, host, port, type=socket.SOCK_STREAM, flags=socket.AI_ADDRCONFIG
)
lookup.add_done_callback(lambda done: self._finish_refresh(key, done))
try:
return self._apply_lookup(
deployment,
deployment_mapping,
key,
url,
self._await_lookup(key, host, lookup),
now,
session_id,
retry_count,
)
finally:
self._end_refresh(key)
async def async_resolve_deployment(
self,
deployment: _DeploymentT,
request_kwargs: Mapping[str, object] | None = None,
) -> _DeploymentT:
eligible: Final = self._eligible(deployment)
if eligible is None:
return deployment
url, key, deployment_mapping = eligible
host, port = key
now: Final = self.clock()
session_id: Final = self._session_id(request_kwargs)
retry_count: Final = self._request_retry_count(request_kwargs)
cached, should_refresh = self._begin_refresh(key, now)
if cached is not None:
return self._deployment_with_cached_ip(
deployment, deployment_mapping, key, url, now, session_id, retry_count
)
if not should_refresh:
return deployment
lookup: Final = asyncio.ensure_future(
asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM, flags=socket.AI_ADDRCONFIG)
)
lookup.add_done_callback(lambda done: self._finish_refresh(key, done))
try:
result: Final = await self._await_async_lookup(key, host, lookup)
return self._apply_lookup(deployment, deployment_mapping, key, url, result, now, session_id, retry_count)
finally:
self._end_refresh(key)
async def async_lookup_pods(self, deployment: object) -> KubernetesPodLookup | None:
eligible: Final = self._eligible(deployment)
if eligible is None:
return None
_, (host, port), _ = eligible
result: Final = await self._async_getaddrinfo(host, port)
if isinstance(result, OSError):
if isinstance(result, socket.gaierror) and self._is_authoritative_no_pods(result):
return KubernetesPodLookup(service_host=host, port=port, pod_ips=(), error=None)
return KubernetesPodLookup(service_host=host, port=port, pod_ips=(), error=str(result))
return KubernetesPodLookup(service_host=host, port=port, pod_ips=self._pod_ips(result), error=None)
@staticmethod
def _session_id(request_kwargs: Mapping[str, object] | None) -> str | None:
if request_kwargs is None:
return None
metadata_dicts: Final = tuple(
cast(Mapping[str, object], metadata) # cast-ok: runtime dict check precedes metadata lookup
for metadata in (request_kwargs.get("litellm_metadata"), request_kwargs.get("metadata"))
if isinstance(metadata, dict)
)
if any(metadata.get(SESSION_ID_GENERATED_METADATA_KEY) for metadata in metadata_dicts):
return None
caller_session_id: Final = next(
(
caller_session
for metadata in metadata_dicts
if (caller_session := metadata.get(CALLER_SESSION_ID_METADATA_KEY)) is not None and caller_session != ""
),
None,
)
if caller_session_id is not None:
return str(caller_session_id)
top_level_session_id: Final = request_kwargs.get("litellm_session_id")
if top_level_session_id is not None and top_level_session_id != "":
return str(top_level_session_id)
metadata_session_id: Final = next(
(
session_id
for metadata in metadata_dicts
if (session_id := metadata.get("session_id")) is not None and session_id != ""
),
None,
)
if metadata_session_id is not None:
return str(metadata_session_id)
return None
@staticmethod
def _request_retry_count(request_kwargs: Mapping[str, object] | None) -> int:
if request_kwargs is None:
return 0
retry_counts: Final = tuple(
_retry_count_from_metadata(request_kwargs.get(metadata_key))
for metadata_key in ("litellm_metadata", "metadata")
)
return next((retry_count for retry_count in retry_counts if retry_count is not None), 0)
def _eligible(self, deployment: object) -> tuple[httpx.URL, _CacheKey, Mapping[str, object]] | None:
if not isinstance(deployment, Mapping):
return None
typed_deployment: Final = cast( # cast-ok: runtime Mapping check precedes typed lookup
Mapping[str, object], deployment
)
target: Final = self._target(typed_deployment)
if target is None:
return None
url, host, port, target_deployment = target
if url.scheme == "https":
self._warn_https_once(host)
return None
if url.scheme != "http" or self._is_ip_literal(host):
return None
return (url, (host, port), target_deployment)
@staticmethod
def _target(
deployment_mapping: Mapping[str, object],
) -> tuple[httpx.URL, str, int | None, Mapping[str, object]] | None:
raw_params: Final = deployment_mapping.get("litellm_params")
if not isinstance(raw_params, Mapping):
return None
params: Final = cast( # cast-ok: runtime Mapping validation precedes the string field checks
Mapping[str, object], raw_params
)
if params.get("kubernetes_pod_discovery") is not True:
return None
raw_api_base: Final = params.get("api_base")
if not isinstance(raw_api_base, str):
return None
try:
url: Final = httpx.URL(raw_api_base)
except httpx.InvalidURL:
return None
return (url, url.host, url.port, deployment_mapping)
def _await_lookup(self, key: _CacheKey, host: str, lookup: _SyncLookup) -> Sequence[_AddressInfo] | OSError:
try:
return lookup.result(timeout=self.dns_timeout_seconds)
except (TimeoutError, concurrent.futures.TimeoutError):
return self._abandon_lookup(key, host, lookup)
except OSError as error:
return error
async def _await_async_lookup(
self, key: _CacheKey, host: str, lookup: _AsyncLookup
) -> Sequence[_AddressInfo] | OSError:
try:
return await asyncio.wait_for(asyncio.shield(lookup), timeout=self.dns_timeout_seconds)
except (TimeoutError, asyncio.TimeoutError):
return self._abandon_lookup(key, host, lookup)
except asyncio.CancelledError:
self._abandon_lookup(key, host, lookup)
raise
except OSError as error:
return error
async def _async_getaddrinfo(self, host: str, port: int | None) -> Sequence[_AddressInfo] | OSError:
lookup: Final = asyncio.get_running_loop().getaddrinfo(
host, port, type=socket.SOCK_STREAM, flags=socket.AI_ADDRCONFIG
)
try:
return await asyncio.wait_for(lookup, timeout=self.dns_timeout_seconds)
except (TimeoutError, asyncio.TimeoutError):
return socket.gaierror(socket.EAI_AGAIN, f"DNS lookup for {host} exceeded {self.dns_timeout_seconds}s")
except OSError as error:
return error
def _abandon_lookup(
self,
key: _CacheKey,
host: str,
lookup: _SyncLookup | _AsyncLookup,
) -> Sequence[_AddressInfo] | OSError:
with self._lock:
if lookup.done():
return _lookup_outcome(lookup)
self._timed_out = self._timed_out | {key}
return socket.gaierror(socket.EAI_AGAIN, f"DNS lookup for {host} exceeded {self.dns_timeout_seconds}s")
def _end_refresh(self, key: _CacheKey) -> None:
with self._lock:
if key not in self._timed_out:
self._refreshing = self._refreshing - {key}
def _finish_refresh(
self,
key: _CacheKey,
done: _SyncLookup | _AsyncLookup,
) -> None:
with self._lock:
timed_out: Final = key in self._timed_out
self._timed_out = self._timed_out - {key}
self._refreshing = self._refreshing - {key} if timed_out else self._refreshing
late_result: Final = _lookup_outcome(done) if timed_out else None
if late_result is None:
return
if not isinstance(late_result, OSError):
self._store(key, self._pod_ips(late_result), self.clock())
elif isinstance(late_result, socket.gaierror) and self._is_authoritative_no_pods(late_result):
self._store(key, (), self.clock())
def _apply_lookup(
self,
deployment: _DeploymentT,
deployment_mapping: Mapping[str, object],
key: _CacheKey,
url: httpx.URL,
result: Sequence[_AddressInfo] | OSError,
now: float,
session_id: str | None,
retry_count: int,
) -> _DeploymentT:
if isinstance(result, OSError):
if isinstance(result, socket.gaierror) and self._is_authoritative_no_pods(result):
self._store(key, (), self.clock())
return deployment
self._stamp_failed_refresh(key, self.clock())
verbose_router_logger.debug("Kubernetes pod discovery DNS refresh failed for %s: %s", key[0], result)
return self._deployment_with_cached_ip(
deployment, deployment_mapping, key, url, now, session_id, retry_count
)
ips: Final = self._pod_ips(result)
self._store(key, ips, self.clock())
if not ips:
return deployment
return self._deployment_with_cached_ip(deployment, deployment_mapping, key, url, now, session_id, retry_count)
@staticmethod
def _is_ip_literal(host: str) -> bool:
try:
ipaddress.ip_address(host)
except ValueError:
return False
return True
@staticmethod
def _is_authoritative_no_pods(error: socket.gaierror) -> bool:
no_pod_errors: Final = (socket.EAI_NONAME, socket.EAI_NODATA)
return error.errno in no_pod_errors
@staticmethod
def _pod_ip(record: _AddressInfo) -> str | None:
address: Final = record[4][0]
return address if isinstance(address, str) else None
@classmethod
def _pod_ips(cls, records: Sequence[_AddressInfo]) -> tuple[str, ...]:
addresses: Final = tuple(cls._pod_ip(record) for record in records)
return tuple(sorted({address for address in addresses if address is not None}))
def _store(self, key: _CacheKey, ips: tuple[str, ...], resolved_at: float) -> None:
with self._lock:
cached: Final = self._cache.get(key)
cursor: Final = cached.cursor % len(ips) if cached is not None and cached.ips and ips else 0
self._cache = MappingProxyType(
{
**self._cache,
key: _PodSet(ips=ips, resolved_at=resolved_at, last_used_at=resolved_at, cursor=cursor),
}
)
def _stamp_failed_refresh(self, key: _CacheKey, resolved_at: float) -> None:
with self._lock:
cached: Final = self._cache.get(key)
stamped: Final = (
replace(cached, resolved_at=resolved_at)
if cached is not None
else _PodSet(ips=(), resolved_at=resolved_at, last_used_at=resolved_at)
)
self._cache = MappingProxyType({**self._cache, key: stamped})
def _next_ip(
self,
key: _CacheKey,
now: float,
session_id: str | None,
retry_count: int,
) -> _PodSelection | None:
session_hash_prefix: Final = (
hashlib.blake2b(f"{session_id}\x00".encode(), digest_size=8) if session_id is not None else None
)
def session_rank(address: str) -> int:
if session_hash_prefix is None:
return 0
h: Final = session_hash_prefix.copy()
h.update(address.encode())
return int.from_bytes(h.digest(), "big")
with self._lock:
cached: Final = self._cache.get(key)
if cached is None or not cached.ips:
return None
pod_count: Final = len(cached.ips)
ranked_ips: Final = (
tuple(
sorted(
cached.ips,
key=lambda address: (-session_rank(address), address),
)
)
if session_id is not None
else ()
)
ip: Final = cached.ips[cached.cursor] if session_id is None else ranked_ips[retry_count % pod_count]
next_cursor: Final = (cached.cursor + 1) % len(cached.ips) if session_id is None else cached.cursor
selection: Final = (
"round_robin"
if session_id is None
else "session_affinity_retry"
if retry_count > 0
else "session_affinity"
)
self._cache = MappingProxyType(
{
**self._cache,
key: replace(cached, cursor=next_cursor, last_used_at=max(cached.last_used_at, now)),
}
)
return ip, pod_count, selection
def _deployment_with_cached_ip(
self,
deployment: _DeploymentT,
deployment_mapping: Mapping[str, object],
key: _CacheKey,
url: httpx.URL,
now: float,
session_id: str | None,
retry_count: int,
) -> _DeploymentT:
selection: Final = self._next_ip(key, now, session_id, retry_count)
if selection is None:
return deployment
ip, pod_count, selection_kind = selection
if self._proxy_bypasses_only_service_host(key, ip):
self._warn_proxy_once(key[0])
return deployment
params: Final = deployment_mapping.get("litellm_params")
if not isinstance(params, Mapping):
return deployment
typed_params: Final = cast( # cast-ok: runtime Mapping validation precedes the copied provider parameters
Mapping[str, object], params
)
return cast( # cast-ok: resolving produces a copied dict for the selector's Mapping type
_DeploymentT,
{
**deployment_mapping,
"litellm_params": {**typed_params, "api_base": str(url.copy_with(host=ip))},
KUBERNETES_POD_ROUTING_KEY: {
"service_host": key[0],
"pod_ip": ip,
"pod_count": pod_count,
"selection": selection_kind,
},
},
)
def _proxy_bypasses_only_service_host(self, key: _CacheKey, ip: str) -> bool:
proxies: Final = self.proxy_environment()
if not (proxies.get("http") or proxies.get("all")):
return False
proxy_bypass_environment: Final = cast( # cast-ok: stdlib helper accepts the supplied proxy mapping
Callable[[str, dict[str, str]], bool],
urllib.request.proxy_bypass_environment, # pyright: ignore[reportAttributeAccessIssue] # typeshed gap
)
no_proxy: Final = proxies.get("no", "")
entries: Final = tuple(entry.strip() for entry in no_proxy.split(","))
host: Final = key[0]
port: Final = key[1] if key[1] is not None else 80
bracketed_ip: Final = f"[{ip}]" if ipaddress.ip_address(ip).version == 6 else ip
service_bypassed: Final = "*" in entries or any(
proxy_bypass_environment(candidate, dict(proxies)) for candidate in (host, f"{host}:{port}")
)
pod_bypassed: Final = any(
proxy_bypass_environment(candidate, dict(proxies)) for candidate in (ip, f"{bracketed_ip}:{port}")
)
return service_bypassed and not pod_bypassed
def _warn_https_once(self, host: str) -> None:
with self._lock:
should_warn: Final = host not in self._https_warning_hosts
if should_warn:
self._https_warning_hosts = self._https_warning_hosts | {host}
if should_warn:
verbose_router_logger.warning(
"Kubernetes pod discovery is disabled for HTTPS host %s because substituting an IP "
"breaks TLS hostname verification",
host,
)
def _warn_proxy_once(self, host: str) -> None:
with self._lock:
should_warn: Final = host not in self._proxy_warning_hosts
if should_warn:
self._proxy_warning_hosts = self._proxy_warning_hosts | {host}
if should_warn:
verbose_router_logger.warning(
"Kubernetes pod discovery keeps the service hostname %s: NO_PROXY exempts it but not its pod IPs",
host,
)
class _HasPodDiscovery(Protocol):
kubernetes_pod_discovery: KubernetesPodDiscovery
_P: Final = ParamSpec("_P")
_R: Final = TypeVar("_R")
_S: Final = TypeVar("_S", bound=_HasPodDiscovery)
def _request_kwargs_from_call(
signature: inspect.Signature,
*args: object,
**kwargs: object, # kwargs-ok: forwards the call to Signature.bind_partial
) -> Mapping[str, object] | None:
try:
bound_arguments: Final = cast( # cast-ok: Signature arguments use string parameter names
Mapping[str, object],
signature.bind_partial(*args, **kwargs).arguments,
)
request_kwargs: Final = bound_arguments.get(
"request_kwargs",
kwargs.get("request_kwargs"),
)
except TypeError:
return _mapping_request_kwargs(kwargs.get("request_kwargs"))
return _mapping_request_kwargs(request_kwargs)
def _mapping_request_kwargs(request_kwargs: object) -> Mapping[str, object] | None:
if not isinstance(request_kwargs, Mapping):
return None
return cast( # cast-ok: router selector request kwargs use string keys
Mapping[str, object],
request_kwargs,
)
def resolve_pods_after(
fn: Callable[Concatenate[_S, _P], _R],
) -> Callable[Concatenate[_S, _P], _R]:
signature: Final = inspect.signature(fn)
@wraps(fn)
def wrapped(
self: _S,
*args: _P.args,
**kwargs: _P.kwargs, # kwargs-ok: ParamSpec forwards the wrapped call signature
) -> _R:
deployment: Final = fn(self, *args, **kwargs)
return self.kubernetes_pod_discovery.resolve_deployment(
deployment,
_request_kwargs_from_call(signature, self, *args, **kwargs),
)
return wrapped
def async_resolve_pods_after(
fn: Callable[Concatenate[_S, _P], Awaitable[_R]],
) -> Callable[Concatenate[_S, _P], Coroutine[object, object, _R]]:
signature: Final = inspect.signature(fn)
@wraps(fn)
async def wrapped(
self: _S,
*args: _P.args,
**kwargs: _P.kwargs, # kwargs-ok: ParamSpec forwards the wrapped call signature
) -> _R:
deployment: Final = await fn(self, *args, **kwargs)
return await self.kubernetes_pod_discovery.async_resolve_deployment(
deployment,
_request_kwargs_from_call(signature, self, *args, **kwargs),
)
return wrapped
def resolve_pods_after_bound(
fn: Callable[_P, _R],
discovery: KubernetesPodDiscovery,
) -> Callable[_P, _R]:
signature: Final = inspect.signature(fn)
@wraps(fn)
def wrapped(
*args: _P.args,
**kwargs: _P.kwargs, # kwargs-ok: ParamSpec forwards the wrapped call signature
) -> _R:
deployment: Final = fn(*args, **kwargs)
return discovery.resolve_deployment(
deployment,
_request_kwargs_from_call(signature, *args, **kwargs),
)
return wrapped
def async_resolve_pods_after_bound(
fn: Callable[_P, Awaitable[_R]],
discovery: KubernetesPodDiscovery,
) -> Callable[_P, Coroutine[object, object, _R]]:
signature: Final = inspect.signature(fn)
@wraps(fn)
async def wrapped(
*args: _P.args,
**kwargs: _P.kwargs, # kwargs-ok: ParamSpec forwards the wrapped call signature
) -> _R:
deployment: Final = await fn(*args, **kwargs)
return await discovery.async_resolve_deployment(
deployment,
_request_kwargs_from_call(signature, *args, **kwargs),
)
return wrapped

View file

@ -1,20 +1,17 @@
"""Resolve which reasoning_effort values a deployment, and by intersection a model group, accepts.
An entry that states its levels outright in reasoning_effort_levels is read first and wins
whole, for a model whose set the per-level flags cannot express: Kimi K3 takes low, high and max,
and no flag can drop medium because medium has none. Every other entry answers through the
supports_*_reasoning_effort flags below, whose polarity mirrors how a request path reads that same
flag. medium and high are unconditional for a reasoning model. minimal and low are opt-out:
openai/chat/gpt_5_transformation.py refuses them only when the map says false. xhigh and max are
opt-in. none is opt-out everywhere except the azure gpt-5 family, whose config raises
UnsupportedParamsError without an explicit true.
An entry that states its levels outright in reasoning_effort_levels wins whole, for a model whose set
the per-level flags cannot express (Kimi K3 takes low, high and max, and no flag can drop medium).
Every other entry answers through its supports_*_reasoning_effort flags, read with the polarity the
chat gate uses: medium and high are unconditional, minimal and low are opt-out (refused only on an
explicit false), xhigh is opt-in, and none is opt-out except on the azure gpt-5 family, whose chat
config refuses it without an explicit true.
xhigh is gated on the request path by the openai and azure gpt-5 configs. max is not gated there at
all: outside the gpt-6-astra rows every entry carrying supports_max_reasoning_effort is Claude-family,
and anthropic/chat/transformation.py gates max on the output_config path while its reasoning_effort
path maps any level to a thinking budget. Making max opt-in is a deliberate trade, then, since an
explicit flag is the only signal that the tier is a real one rather than litellm rounding the level
to a budget, and a missing flag costs advisory metadata rather than a rejected request.
The Responses gate reads none, minimal, low and xhigh as opt-out. Neither gate judges max, which
Chat Completions refuses and Responses honors on the gpt-6 family, so its flag is advisory and
advertised opt-in: outside the gpt-6 rows every entry carrying supports_max_reasoning_effort is
Claude-family, where anthropic/chat/transformation.py gates max on the output_config path, so an
explicit flag is the only signal that the tier is real.
A deployment the map describes with no effort flags at all resolves to None rather than to the
opt-out defaults. 689 of the map's 854 reasoning entries carry no flag, and the o-series, xai and

View file

@ -183,6 +183,7 @@ class DeploymentOptions:
tpm: int | None = None
itpm: int | None = None
otpm: int | None = None
kubernetes_pod_discovery: bool | None = None
default_api_key_rpm_limit: int | None = None
default_api_key_tpm_limit: int | None = None
max_parallel_requests: int | None = None

View file

@ -43,6 +43,7 @@ from .llms.bedrock import AwsSessionTag
from .llms.openai import OpenAIFileObject
from .search import SearchProvider
from .utils import (
CooldownDeploymentInfo,
CustomPricingLiteLLMParams,
MirroredPricingParams,
ModelResponse,
@ -720,6 +721,7 @@ class LiteLLMParamsTypedDict(TypedDict, total=False):
use_chat_completions_api: bool | None
## PASS-THROUGH ENDPOINTS ##
use_in_pass_through: bool | None
kubernetes_pod_discovery: ReadOnly[bool | None]
litellm_credential_name: str | None
## UNIFIED PROJECT/REGION ##
region_name: str | None
@ -1105,6 +1107,7 @@ class RouterErrorTypes(str, enum.Enum):
class RouterRateLimitError(ValueError):
status_code: Final = 429
cooldown_deployments: tuple[CooldownDeploymentInfo, ...] | None = None
def __init__(
self,
@ -1113,11 +1116,13 @@ class RouterRateLimitError(ValueError):
enable_pre_call_checks: bool,
cooldown_list: list,
model_ids: Sequence[str] = (),
cooldown_deployments: Sequence[CooldownDeploymentInfo] | None = None,
) -> None:
self.model = model
self.cooldown_time = cooldown_time
self.enable_pre_call_checks = enable_pre_call_checks
self.cooldown_list = cooldown_list
self.cooldown_deployments = None if cooldown_deployments is None else tuple(cooldown_deployments)
self.all_deployments_in_cooldown = bool(model_ids) and frozenset(model_ids) <= frozenset(cooldown_list)
self.type = (
RouterErrorTypes.all_deployments_in_cooldown.value

View file

@ -3242,6 +3242,13 @@ class StandardLoggingRoutingDecision(TypedDict, total=False):
tier_litellm_params: Mapping[str, object] # writable-ok: Pydantic warns on ReadOnly TypedDict fields
class StandardLoggingKubernetesPodRouting(TypedDict):
service_host: ReadOnly[str]
pod_ip: ReadOnly[str]
pod_count: ReadOnly[int]
selection: ReadOnly[Literal["round_robin", "session_affinity", "session_affinity_retry"]]
# Fields whose values quote the caller's prompt. Dropped when an operator turns message
# logging off. Every other field aggregates the prompt without reproducing it and is kept,
# so a redacted row stays explainable. `test_every_routing_decision_field_is_classified`
@ -3308,6 +3315,7 @@ class StandardLoggingMetadata(StandardLoggingUserAPIKeyMetadata):
mcp_tool_call_metadata: StandardLoggingMCPToolCall | None
vector_store_request_metadata: list[StandardLoggingVectorStoreRequest] | None
routing_decision: StandardLoggingRoutingDecision | None
kubernetes_pod_routing: ReadOnly[StandardLoggingKubernetesPodRouting | None]
applied_guardrails: list[str] | None
usage_object: dict | None
cold_storage_object_key: str | None # S3/GCS object key for cold storage retrieval
@ -3378,6 +3386,11 @@ class StandardLoggingZeroCostDiagnostic(TypedDict):
missing_pricing_keys: ReadOnly[tuple[str, ...]]
class CooldownDeploymentInfo(TypedDict):
model_id: ReadOnly[str]
api_base: ReadOnly[str | None]
class StandardLoggingPayloadErrorInformation(TypedDict, total=False):
error_code: str | None
error_class: str | None
@ -3406,6 +3419,7 @@ class StandardLoggingPayloadErrorInformation(TypedDict, total=False):
error_budget_entity_id: str | None
error_budget_limit: float | None
error_budget_spend: float | None
error_cooldown_deployments: ReadOnly[tuple[CooldownDeploymentInfo, ...] | None]
normalized_error: ReadOnly[str | None]
@ -4455,6 +4469,7 @@ class SearchProviders(str, Enum):
AGENTCORE = "agentcore"
NIMBLE = "nimble"
BING_GROUNDING = "bing_grounding"
DEEPSEEK = "deepseek"
# Create a set of all search provider values for quick lookup

View file

@ -9704,6 +9704,8 @@ class ProviderConfigManager:
) -> BaseLLMModelInfo | None:
if LlmProviders.FIREWORKS_AI == provider:
return litellm.FireworksAIConfig()
elif LlmProviders.BASETEN == provider:
return litellm.BasetenConfig()
elif LlmProviders.OPENAI == provider:
return litellm.OpenAIGPTConfig()
elif LlmProviders.GEMINI == provider:
@ -10295,6 +10297,7 @@ class ProviderConfigManager:
from litellm.llms.bedrock.search.transformation import AgentCoreSearchConfig
from litellm.llms.brave.search.transformation import BraveSearchConfig
from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig
from litellm.llms.deepseek.search.transformation import DeepSeekSearchConfig
from litellm.llms.duckduckgo.search.transformation import DuckDuckGoSearchConfig
from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig
from litellm.llms.fastcrw.search.transformation import FastCRWSearchConfig
@ -10334,6 +10337,7 @@ class ProviderConfigManager:
SearchProviders.AGENTCORE: AgentCoreSearchConfig,
SearchProviders.NIMBLE: NimbleSearchConfig,
SearchProviders.BING_GROUNDING: BingGroundingSearchConfig,
SearchProviders.DEEPSEEK: DeepSeekSearchConfig,
}
config_class: Final = PROVIDER_TO_CONFIG_MAP.get(provider, None)
if config_class is None:

View file

@ -3895,7 +3895,7 @@
"supports_max_reasoning_effort": false,
"supports_minimal_reasoning_effort": false,
"supports_native_streaming": true,
"supports_none_reasoning_effort": true,
"supports_none_reasoning_effort": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_cache_breakpoint": true,
@ -8409,7 +8409,7 @@
"supports_max_reasoning_effort": false,
"supports_minimal_reasoning_effort": false,
"supports_native_streaming": true,
"supports_none_reasoning_effort": true,
"supports_none_reasoning_effort": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
@ -8458,7 +8458,7 @@
"supports_max_reasoning_effort": false,
"supports_minimal_reasoning_effort": false,
"supports_native_streaming": true,
"supports_none_reasoning_effort": true,
"supports_none_reasoning_effort": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
@ -9252,7 +9252,7 @@
"supports_max_reasoning_effort": false,
"supports_minimal_reasoning_effort": false,
"supports_native_streaming": true,
"supports_none_reasoning_effort": true,
"supports_none_reasoning_effort": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
@ -24366,6 +24366,11 @@
"notes": "Grounding with Bing Search (G1 SKU): $14 per 1,000 transactions. Tokens for the Foundry model deployment that runs the grounded search are billed separately on that deployment."
}
},
"deepseek/search": {
"input_cost_per_query": 0.00075,
"litellm_provider": "deepseek",
"mode": "search"
},
"tinyfish/search": {
"input_cost_per_query": 0.0,
"litellm_provider": "tinyfish",
@ -32524,6 +32529,7 @@
"github_copilot/claude-opus-4.8": {
"cache_creation_input_token_cost": 6.25e-06,
"cache_read_input_token_cost": 5e-07,
"deprecation_date": "2026-11-16",
"input_cost_per_token": 5e-06,
"litellm_provider": "github_copilot",
"max_input_tokens": 200000,
@ -33351,9 +33357,9 @@
"supports_response_schema": true,
"supports_vision": true,
"supports_reasoning": true,
"supports_none_reasoning_effort": false,
"supports_xhigh_reasoning_effort": false,
"supports_minimal_reasoning_effort": true
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false
},
"chatgpt/gpt-5.3-codex-spark": {
"litellm_provider": "chatgpt",
@ -33684,24 +33690,28 @@
"output_cost_per_token": 1.2e-06
},
"baseten/MiniMaxAI/MiniMax-M2.5": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 3e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 1.2e-06
},
"baseten/nvidia/Nemotron-120B-A12B": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 3e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 7.5e-07
},
"baseten/zai-org/GLM-5": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 9.5e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 3.15e-06
},
"baseten/zai-org/GLM-4.7": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1.2e-07,
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
@ -33719,24 +33729,28 @@
"supports_vision": false
},
"baseten/zai-org/GLM-4.6": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 2.2e-06
},
"baseten/moonshotai/Kimi-K2.5": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 3e-06
},
"baseten/moonshotai/Kimi-K2-Thinking": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 2.5e-06
},
"baseten/moonshotai/Kimi-K2-Instruct-0905": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
@ -33760,12 +33774,14 @@
"supports_vision": false
},
"baseten/deepseek-ai/DeepSeek-V3.1": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 5e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 1.5e-06
},
"baseten/deepseek-ai/DeepSeek-V3-0324": {
"deprecation_date": "2026-10-16",
"input_cost_per_token": 7.7e-07,
"litellm_provider": "baseten",
"mode": "chat",
@ -37796,9 +37812,9 @@
"supports_tool_search": true,
"supports_vision": true,
"supports_web_search": true,
"supports_none_reasoning_effort": false,
"supports_xhigh_reasoning_effort": false,
"supports_minimal_reasoning_effort": true,
"supports_none_reasoning_effort": true,
"supports_xhigh_reasoning_effort": true,
"supports_minimal_reasoning_effort": false,
"deprecation_date": "2027-04-01"
},
"gpt-5.3-chat-latest": {
@ -69613,7 +69629,7 @@
"supports_max_reasoning_effort": false,
"supports_minimal_reasoning_effort": false,
"supports_native_streaming": true,
"supports_none_reasoning_effort": true,
"supports_none_reasoning_effort": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
@ -72125,6 +72141,7 @@
"supports_vision": true
},
"baseten/moonshotai/Kimi-K2.6": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1.6e-07,
"input_cost_per_token": 9.5e-07,
"litellm_provider": "baseten",
@ -72142,6 +72159,7 @@
"supports_vision": true
},
"baseten/moonshotai/Kimi-K2.7-Code": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1.6e-07,
"input_cost_per_token": 9.5e-07,
"litellm_provider": "baseten",
@ -72193,6 +72211,7 @@
"supports_vision": false
},
"baseten/thinkingmachines/inkling": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1.7e-07,
"input_cost_per_token": 1e-06,
"litellm_provider": "baseten",
@ -72210,6 +72229,7 @@
"supports_vision": true
},
"baseten/thinkingmachines/inkling-small": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 5e-07,
"litellm_provider": "baseten",
@ -72278,6 +72298,7 @@
"supports_vision": false
},
"baseten/deepseek-ai/DeepSeek-V4-Pro": {
"deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 1.45e-07,
"input_cost_per_token": 1.74e-06,
"litellm_provider": "baseten",
@ -75951,11 +75972,11 @@
"output_cost_per_token": 1.2e-06,
"output_cost_per_token_priority": 1.5e-06,
"source": "https://api.fireworks.ai/v1/serverless/models?format=nested",
"supports_function_calling": false,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": false,
"supports_tool_choice": true,
"supports_vision": true
},
"github_copilot/claude-sonnet-4.6": {
@ -76100,5 +76121,41 @@
"supported_output_modalities": [
"image"
]
},
"fireworks_ai/accounts/fireworks/models/minimax-m3p1-flash": {
"cache_read_input_token_cost": 1.5e-08,
"cache_read_input_token_cost_priority": 2.25e-08,
"input_cost_per_token": 1.5e-07,
"input_cost_per_token_priority": 2.25e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"mode": "chat",
"output_cost_per_token": 6e-07,
"output_cost_per_token_priority": 9e-07,
"source": "https://platform.minimax.io/docs/guides/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"meta/sam-3.1": {
"input_cost_per_image": 0.0025,
"litellm_provider": "meta",
"mode": "responses",
"source": "https://dev.meta.ai/docs/pricing-rate-limits.md",
"supported_endpoints": [
"/v1/responses"
],
"supported_modalities": [
"text",
"image",
"video"
],
"supported_output_modalities": [
"text"
]
}
}

View file

@ -24,6 +24,7 @@ dependencies = [
"click>=8.0.0,<9.0",
"jinja2>=3.1.6,<4.0",
"aiohttp>=3.14.2,<4.0",
"anyio>=4.14.0,<5.0",
"async-timeout>=4.0.3,<6.0; python_version < '3.11'",
"pydantic>=2.11.0,<3.0.0; python_version < '3.14'",
"pydantic>=2.12.0,<3.0.0; python_version >= '3.14'",

View file

@ -923,7 +923,8 @@
"batches": false,
"rerank": false,
"a2a": true,
"interactions": true
"interactions": true,
"search": true
}
},
"edenai": {

View file

@ -29,6 +29,7 @@ dependencies = [
"click>=8.0.0,<9.0",
"jinja2>=3.1.6,<4.0",
"aiohttp>=3.14.2,<4.0",
"anyio>=4.14.0,<5.0",
"async-timeout>=4.0.3,<6.0; python_version < '3.11'",
"pydantic>=2.11.0,<3.0.0; python_version < '3.14'",
"pydantic>=2.12.0,<3.0.0; python_version >= '3.14'",

View file

@ -74,6 +74,7 @@ GET /guardrails/ui/major_airlines
GET /guardrails/ui/provider_specific_params
GET /key/aliases
GET /model/deprecations
GET /model/{model_id}/kubernetes_pods
GET /search_tools/ui/available_providers
GET /team/available
GET /team/metadata_schema

View file

@ -42,9 +42,11 @@ def get_all_functions_called_in_tests(base_dir):
print("dir_path: ", dir_path)
for root, _, files in os.walk(dir_path):
for file in files:
if file.endswith(".py") and ("router" in file.lower() or test_dir == "unit"):
file_path = os.path.join(root, file)
if file.endswith(".py") and (
"router" in os.path.relpath(file_path, dir_path).lower() or test_dir == "unit"
):
print("file: ", file)
file_path = os.path.join(root, file)
with open(file_path, "r", encoding="utf-8") as f:
try:
tree = ast.parse(f.read())

View file

@ -3494,6 +3494,10 @@ GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES: Final = bridge_pairs(
(GEMINI_GEMINI_4_ARGON_INPUT_TEXT, GEMINI_GEMINI_4_ARGON_STREAM),
("/v1/responses", "/v1/messages"),
)
GEMINI_GEMINI_3_8_FLASH_ROUTE_BRIDGES: Final = bridge_pairs(
(GEMINI_GEMINI_3_8_FLASH_INPUT_TEXT, GEMINI_GEMINI_3_8_FLASH_STREAM),
("/v1/responses", "/v1/messages"),
)
GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES: Final = bridge_pairs(
(GEMINI_GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT, GEMINI_GEMINI_3_1_PRO_PREVIEW_STREAM),
("/v1/responses", "/v1/messages"),
@ -3572,6 +3576,7 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = (
GEMINI_GEMINI_3_1_PRO_PREVIEW_TOOL_CALL,
GEMINI_GEMINI_3_1_PRO_PREVIEW_SERVICE_TIER_PRIORITY,
*(pair.bridged for pair in GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES),
*(pair.bridged for pair in GEMINI_GEMINI_3_8_FLASH_ROUTE_BRIDGES),
*(pair.bridged for pair in GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES),
)
@ -3606,6 +3611,14 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = (
plain=GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES[1].bridged,
streamed=GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES[3].bridged,
),
StreamParityTestCase(
plain=GEMINI_GEMINI_3_8_FLASH_ROUTE_BRIDGES[0].bridged,
streamed=GEMINI_GEMINI_3_8_FLASH_ROUTE_BRIDGES[2].bridged,
),
StreamParityTestCase(
plain=GEMINI_GEMINI_3_8_FLASH_ROUTE_BRIDGES[1].bridged,
streamed=GEMINI_GEMINI_3_8_FLASH_ROUTE_BRIDGES[3].bridged,
),
StreamParityTestCase(
plain=GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[0].bridged,
streamed=GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[2].bridged,
@ -3618,14 +3631,10 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = (
BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = (
*bridge_pairs(
(
GEMINI_GEMINI_3_8_FLASH_INPUT_TEXT,
GEMINI_GEMINI_3_8_FLASH_CACHE_READ,
GEMINI_GEMINI_3_8_FLASH_REASONING,
GEMINI_GEMINI_3_8_FLASH_STREAM,
),
(GEMINI_GEMINI_3_8_FLASH_CACHE_READ, GEMINI_GEMINI_3_8_FLASH_REASONING),
("/v1/responses", "/v1/messages"),
),
*GEMINI_GEMINI_3_8_FLASH_ROUTE_BRIDGES,
*GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES,
*GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES,
)

View file

@ -12008,6 +12008,9 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = (
GPT_6_ASTRA_RESPONSES_NATIVE_STREAM,
GPT_5_6_TERRA_RESPONSES_NATIVE_STREAM,
GPT_5_6_TERRA_MESSAGES_BRIDGED_JSON,
GPT_6_1_SOL_MESSAGES_BRIDGED_JSON,
GPT_6_LUNA_MESSAGES_BRIDGED_JSON,
GPT_6_ASTRA_MESSAGES_BRIDGED_JSON,
GPT_6_1_SOL_MESSAGES_BRIDGED_STREAM,
GPT_6_LUNA_MESSAGES_BRIDGED_STREAM,
GPT_6_ASTRA_MESSAGES_BRIDGED_STREAM,

View file

@ -3146,6 +3146,41 @@ VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_READ: Final = _vertex_claude_shape_case(
cache_read_cost=0.00351039,
),
)
VERTEX_AI_CLAUDE_HAIKU_5_5_TIERED_INPUT_ABOVE_100K: Final = _vertex_claude_shape_case(
VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT,
"vertex_ai/claude-haiku-5-5-tiered_input_above_100k",
"vertex_ai/claude-haiku-5-5",
_vertex_claude_usage(input_tokens=150000, output_tokens=480),
ExactExpected(
spend=0.9906,
input_cost=0.975,
output_cost=0.0156,
prompt_tokens=150000,
completion_tokens=480,
),
response_content=[
{"type": "thinking", "thinking": "scripted thought", "signature": "scripted signature"},
{"type": "text", "text": "scripted answer for cost fixtures"},
],
)
VERTEX_AI_CLAUDE_HAIKU_5_5_TIERED_CACHE_READ_ABOVE_100K: Final = _vertex_claude_shape_case(
VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_READ,
"vertex_ai/claude-haiku-5-5-tiered_cache_read_above_100k",
"vertex_ai/claude-haiku-5-5",
_vertex_claude_usage(input_tokens=4096, output_tokens=480, cache_read_input_tokens=110592),
ExactExpected(
spend=0.1141088,
input_cost=0.0985088,
output_cost=0.0156,
prompt_tokens=114688,
completion_tokens=480,
cache_read_cost=0.0718848,
),
response_content=[
{"type": "thinking", "thinking": "scripted thought", "signature": "scripted signature"},
{"type": "text", "text": "scripted answer for cost fixtures"},
],
)
VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_WRITE_5M: Final = _vertex_claude_shape_case(
VERTEX_AI_CLAUDE_SONNET_5_5_CACHE_WRITE_5M,
"vertex_ai/claude-haiku-5-5-cache_write_5m",
@ -3530,6 +3565,10 @@ GEMINI_4_ARGON_ROUTE_BRIDGES: Final = bridge_pairs(
(GEMINI_4_ARGON_INPUT_TEXT, GEMINI_4_ARGON_STREAM),
("/v1/responses", "/v1/messages"),
)
GEMINI_3_8_FLASH_ROUTE_BRIDGES: Final = bridge_pairs(
(GEMINI_3_8_FLASH_INPUT_TEXT, GEMINI_3_8_FLASH_STREAM),
("/v1/responses", "/v1/messages"),
)
GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES: Final = bridge_pairs(
(GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT, GEMINI_3_1_PRO_PREVIEW_STREAM),
("/v1/responses", "/v1/messages"),
@ -3604,6 +3643,8 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = (
VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT,
VERTEX_AI_CLAUDE_HAIKU_5_5_STREAM,
VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_READ,
VERTEX_AI_CLAUDE_HAIKU_5_5_TIERED_INPUT_ABOVE_100K,
VERTEX_AI_CLAUDE_HAIKU_5_5_TIERED_CACHE_READ_ABOVE_100K,
VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_WRITE_5M,
VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_WRITE_1H,
VERTEX_AI_CLAUDE_HAIKU_5_5_THINKING,
@ -3633,6 +3674,7 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = (
GEMINI_4_ARGON_REASONING,
GEMINI_4_ARGON_TOOL_CALL,
*(pair.bridged for pair in GEMINI_4_ARGON_ROUTE_BRIDGES),
*(pair.bridged for pair in GEMINI_3_8_FLASH_ROUTE_BRIDGES),
*(pair.bridged for pair in GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES),
)
@ -3699,6 +3741,14 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = (
plain=GEMINI_4_ARGON_ROUTE_BRIDGES[1].bridged,
streamed=GEMINI_4_ARGON_ROUTE_BRIDGES[3].bridged,
),
StreamParityTestCase(
plain=GEMINI_3_8_FLASH_ROUTE_BRIDGES[0].bridged,
streamed=GEMINI_3_8_FLASH_ROUTE_BRIDGES[2].bridged,
),
StreamParityTestCase(
plain=GEMINI_3_8_FLASH_ROUTE_BRIDGES[1].bridged,
streamed=GEMINI_3_8_FLASH_ROUTE_BRIDGES[3].bridged,
),
StreamParityTestCase(
plain=GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[0].bridged,
streamed=GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[2].bridged,
@ -3711,9 +3761,10 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = (
BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = (
*bridge_pairs(
(GEMINI_3_8_FLASH_INPUT_TEXT, GEMINI_3_8_FLASH_CACHE_READ, GEMINI_3_8_FLASH_STREAM),
(GEMINI_3_8_FLASH_CACHE_READ, GEMINI_3_8_FLASH_REASONING),
("/v1/responses", "/v1/messages"),
),
*GEMINI_3_8_FLASH_ROUTE_BRIDGES,
*GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES,
*GEMINI_4_ARGON_ROUTE_BRIDGES,
*VERTEX_AI_CLAUDE_HAIKU_5_5_BRIDGE_PAIRS,

View file

@ -2836,9 +2836,9 @@ def test_client_disconnect_before_any_content_sends_no_response_scan(gateway: Ga
with _disconnect_rig(gateway, tmp_path, shape="empty") as rig:
try:
_stream_and_close(rig, "/v1/chat/completions", _chat_body(rig, 0), "data: ")
rows: Final = _post_call_statuses(rig, rig.model)
finally:
rig.gate.set()
rows: Final = _post_call_statuses(rig, rig.model)
assert rig.scans.response_scans(rig.secret()) == (), rig.scans.seen
assert len(rows) == 1 and "success" not in rows[0], rows

View file

@ -0,0 +1,383 @@
import json
import re
import uuid
from collections.abc import Iterator, Mapping, Sequence
from contextlib import ExitStack
from dataclasses import dataclass
from itertools import chain
from pathlib import Path
from typing import Final
import httpx
import pytest
import yaml
from integration._support.client import (
JSON_OBJECT,
Gateway,
Scenario,
gateway_from_environment,
object_value,
string_value,
)
from integration._support.openai_wire import chat_reply
from integration._support.process import graceful_stop_seconds, owned_proxy_process
from integration._support.wire import Reply, Request, Wire, wire_server
from litellm.litellm_core_utils.default_encoding import encoding as cl100k_base
from pydantic import JsonValue
pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
_API_KEY: Final = "scripted-provider-key"
_DEEPSEEK_V4: Final = "azure_ai/DeepSeek-V4-Flash-0731"
_QWEN3_5: Final = "databricks/databricks-qwen35-122b-a10b"
_GROK: Final = "azure_ai/grok-4-1"
_DEEPSEEK_V4_TOOL_PREAMBLE: Final = 222
_QWEN3_5_TOOL_PREAMBLE: Final = 196
_QWEN3_5_OPENED_SYSTEM_TURN: Final = 5
_OPENAI_TOOL_OVERHEAD: Final = 9
_SCHEMA: Final[dict[str, JsonValue]] = {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
}
_FUNCTION: Final[dict[str, JsonValue]] = {"name": "lookup", "parameters": _SCHEMA}
_OPENAI_NAMESPACE: Final = "namespace functions {\n\ntype lookup = (_: {\nquery: string,\n}) => any;\n\n} // namespace functions"
_ANTHROPIC_TOOL: Final[dict[str, JsonValue]] = {"name": "lookup", "input_schema": _SCHEMA}
_OPENAI_TOOL: Final[dict[str, JsonValue]] = {"type": "function", "function": _FUNCTION}
_RESPONSES_TOOL: Final[dict[str, JsonValue]] = {"type": "function", **_FUNCTION}
_NAMED_TOOL_CHOICE: Final[dict[str, JsonValue]] = {"type": "function", "function": {"name": "lookup"}}
_WEB_SEARCH_TOOL: Final[dict[str, JsonValue]] = {"type": "web_search_20250305", "name": "web_search", "max_uses": 3}
_PROMPT: Final = "Find record 14."
_MESSAGES: Final[list[JsonValue]] = [{"role": "user", "content": _PROMPT}]
_INPUT_PRICE: Final = 0.001
_OUTPUT_PRICE: Final = 0.002
_TINY_BUDGET: Final = 0.000000001
_ESTIMATED_COST: Final = re.compile(r"Estimated request cost: ([^\s,]+)")
def _tokens(text: str) -> int:
return len(cl100k_base.encode(text))
def _deepseek_v4_tool_tokens(function: Mapping[str, JsonValue]) -> int:
return _DEEPSEEK_V4_TOOL_PREAMBLE + _tokens(json.dumps(function, ensure_ascii=False))
def _qwen3_5_tool_tokens(function: Mapping[str, JsonValue], *, with_system: bool) -> int:
opened_system_turn: Final = 0 if with_system else _QWEN3_5_OPENED_SYSTEM_TURN
schema: Final = json.dumps({"type": "function", "function": function}, ensure_ascii=False)
return _QWEN3_5_TOOL_PREAMBLE + opened_system_turn + _tokens("\n" + schema)
def _completion(_request: Request) -> Reply:
return chat_reply(f"chatcmpl-{uuid.uuid4().hex}", "scripted-model", "OK", stream=False)
def _deployment(scenario: Scenario, group: str, model: str, api_base: str) -> str:
name: Final = f"{group}-{uuid.uuid4().hex}"
created: Final = scenario.gateway.post(
"/model/new",
{
"model_name": name,
"litellm_params": {"model": model, "api_base": api_base, "api_key": _API_KEY},
"model_info": {},
},
)
scenario.cleanups.callback(scenario.delete_model, string_value(object_value(created["model_info"])["id"]))
return name
@dataclass(frozen=True, slots=True)
class _CountRoute:
path: str
field: str
tool: dict[str, JsonValue]
def body(self, model: str, tools: Sequence[JsonValue]) -> dict[str, JsonValue]:
prompt: Final[dict[str, JsonValue]] = (
{"input": _PROMPT} if self.path == "/v1/responses/input_tokens" else {"messages": _MESSAGES}
)
return {"model": model, **prompt, **({"tools": list(tools)} if tools else {})}
_COUNT_ROUTES: Final[Mapping[str, _CountRoute]] = {
"messages-count-tokens": _CountRoute("/v1/messages/count_tokens", "input_tokens", _ANTHROPIC_TOOL),
"utils-token-counter": _CountRoute("/utils/token_counter", "total_tokens", _OPENAI_TOOL),
"responses-input-tokens": _CountRoute("/v1/responses/input_tokens", "input_tokens", _RESPONSES_TOOL),
}
_MESSAGES_COUNT: Final = _COUNT_ROUTES["messages-count-tokens"]
def _count(gateway: Gateway, route: _CountRoute, body: Mapping[str, JsonValue]) -> int:
response: Final = gateway.request("POST", route.path, body)
assert response.status_code == 200, response.text
counted: Final = JSON_OBJECT.validate_json(response.content)[route.field]
assert isinstance(counted, int), response.text
return counted
@pytest.mark.parametrize("route", sorted(_COUNT_ROUTES))
def test_count_routes_count_deepseek_v4_tools_the_way_its_chat_template_renders_them(
gateway: Gateway, route: str
) -> None:
selected: Final = _COUNT_ROUTES[route]
with wire_server(_completion) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, "count", _DEEPSEEK_V4, wire.url)
without_tools: Final = _count(gateway, selected, selected.body(model, ()))
with_tool: Final = _count(gateway, selected, selected.body(model, (selected.tool,)))
upstream: Final = wire.drain()
assert upstream == ()
assert with_tool - without_tools == _deepseek_v4_tool_tokens(_FUNCTION)
@pytest.mark.parametrize("with_system", (False, True), ids=("no-system", "system"))
def test_messages_count_tokens_counts_qwen3_5_tools_the_way_its_chat_template_renders_them(
gateway: Gateway, with_system: bool
) -> None:
system: Final[dict[str, JsonValue]] = {"system": "Be brief."} if with_system else {}
with wire_server(_completion) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, "count", _QWEN3_5, wire.url)
without_tools: Final = _count(gateway, _MESSAGES_COUNT, {**_MESSAGES_COUNT.body(model, ()), **system})
with_tool: Final = _count(
gateway, _MESSAGES_COUNT, {**_MESSAGES_COUNT.body(model, (_ANTHROPIC_TOOL,)), **system}
)
upstream: Final = wire.drain()
assert upstream == ()
assert with_tool - without_tools == _qwen3_5_tool_tokens(_FUNCTION, with_system=with_system)
def test_messages_count_tokens_skips_web_search_and_counts_unmapped_tool_keys_for_deepseek_v4(
gateway: Gateway,
) -> None:
deferred_function: Final[dict[str, JsonValue]] = {"name": "lookup", "parameters": {**_SCHEMA, "defer_loading": True}}
with wire_server(_completion) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, "count", _DEEPSEEK_V4, wire.url)
plain: Final = _count(gateway, _MESSAGES_COUNT, _MESSAGES_COUNT.body(model, (_ANTHROPIC_TOOL,)))
with_web_search: Final = _count(
gateway, _MESSAGES_COUNT, _MESSAGES_COUNT.body(model, (_ANTHROPIC_TOOL, _WEB_SEARCH_TOOL))
)
deferred: Final = _count(
gateway, _MESSAGES_COUNT, _MESSAGES_COUNT.body(model, ({**_ANTHROPIC_TOOL, "defer_loading": True},))
)
upstream: Final = wire.drain()
assert upstream == ()
assert with_web_search == plain
assert deferred - plain == _deepseek_v4_tool_tokens(deferred_function) - _deepseek_v4_tool_tokens(_FUNCTION)
def test_messages_count_tokens_keeps_openai_tool_framing_for_other_models(gateway: Gateway) -> None:
with wire_server(_completion) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, "count", _GROK, wire.url)
without_tools: Final = _count(gateway, _MESSAGES_COUNT, _MESSAGES_COUNT.body(model, ()))
plain: Final = _count(gateway, _MESSAGES_COUNT, _MESSAGES_COUNT.body(model, (_ANTHROPIC_TOOL,)))
deferred: Final = _count(
gateway, _MESSAGES_COUNT, _MESSAGES_COUNT.body(model, ({**_ANTHROPIC_TOOL, "defer_loading": True},))
)
upstream: Final = wire.drain()
assert upstream == ()
assert plain - without_tools == _OPENAI_TOOL_OVERHEAD + _tokens(_OPENAI_NAMESPACE)
assert deferred == plain
@dataclass(frozen=True, slots=True)
class _Endpoint:
path: str
body: dict[str, JsonValue]
tools: dict[str, JsonValue]
_ENDPOINTS: Final[Mapping[str, _Endpoint]] = {
"chat-completions": _Endpoint(
"/v1/chat/completions",
{"messages": _MESSAGES, "max_tokens": 1},
{"tools": [_OPENAI_TOOL], "tool_choice": _NAMED_TOOL_CHOICE},
),
"messages": _Endpoint(
"/v1/messages",
{"messages": _MESSAGES, "max_tokens": 1},
{"tools": [_ANTHROPIC_TOOL], "tool_choice": {"type": "auto"}},
),
"responses": _Endpoint(
"/v1/responses",
{"input": _PROMPT, "max_output_tokens": 16},
{"tools": [_RESPONSES_TOOL]},
),
}
def _send(gateway: Gateway, endpoint: _Endpoint, group: str, key: str, *, tools: bool) -> httpx.Response:
return gateway.request(
"POST",
endpoint.path,
{"model": group, **endpoint.body, **(endpoint.tools if tools else {})},
key=key,
)
@dataclass(frozen=True, slots=True)
class _BudgetRig:
gateway: Gateway
wire: Wire
group: str
def _strict_budget_config(directory: Path, group: str, api_base: str) -> Path:
base: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
config: Final = {
**base,
"model_list": [
{
"model_name": group,
"litellm_params": {
"model": _DEEPSEEK_V4,
"api_base": api_base,
"api_key": _API_KEY,
"input_cost_per_token": _INPUT_PRICE,
"output_cost_per_token": _OUTPUT_PRICE,
},
}
],
"general_settings": {**object_value(base["general_settings"]), "fail_closed_budget_enforcement": True},
}
path: Final = directory / f"chat-template-tool-budget-{uuid.uuid4().hex[:8]}.yaml"
path.write_text(yaml.safe_dump(config))
return path
@pytest.fixture(scope="module")
def strict_budget(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_BudgetRig]:
directory: Final = tmp_path_factory.mktemp("chat-template-tool-budget")
group: Final = f"deepseek-v4-flash-{uuid.uuid4().hex}"
with ExitStack() as stack:
rig_gateway: Final = stack.enter_context(gateway_from_environment())
wire: Final = stack.enter_context(wire_server(_completion))
owned: Final = stack.enter_context(
owned_proxy_process(rig_gateway, directory, {}, config=_strict_budget_config(directory, group, wire.url))
)
yield _BudgetRig(owned.gateway, wire, group)
def _estimated_cost(response: httpx.Response) -> float:
assert response.status_code in (400, 422, 429), f"{response.status_code} {response.text}"
error: Final = object_value(JSON_OBJECT.validate_json(response.content)["error"])
estimate: Final = _ESTIMATED_COST.search(string_value(error["message"]))
assert estimate is not None, response.text
return float(estimate[1])
@pytest.mark.parametrize("endpoint", ("chat-completions", "messages"))
def test_budget_reservation_estimates_deepseek_v4_tools_the_way_its_chat_template_renders_them(
strict_budget: _BudgetRig, endpoint: str
) -> None:
selected: Final = _ENDPOINTS[endpoint]
with strict_budget.gateway.scenario() as scenario:
key: Final = scenario.key(models=[strict_budget.group], max_budget=_TINY_BUDGET)
without_tools: Final = _estimated_cost(_send(strict_budget.gateway, selected, strict_budget.group, key, tools=False))
with_tools: Final = _estimated_cost(_send(strict_budget.gateway, selected, strict_budget.group, key, tools=True))
assert strict_budget.wire.drain() == ()
assert with_tools - without_tools == pytest.approx(_deepseek_v4_tool_tokens(_FUNCTION) * _INPUT_PRICE)
def _project_key(scenario: Scenario, group: str) -> str:
team: Final = scenario.team()
project: Final = scenario.project(team, model_itpm_limit={group: _deepseek_v4_tool_tokens(_FUNCTION)})
return scenario.key(team_id=team, project_id=project)
@pytest.mark.parametrize("endpoint", sorted(_ENDPOINTS))
def test_project_itpm_reserves_deepseek_v4_tools_the_way_its_chat_template_renders_them(
gateway: Gateway, endpoint: str
) -> None:
with wire_server(_completion) as wire, gateway.scenario() as scenario:
group: Final = _deployment(scenario, "deepseek-v4-flash", _DEEPSEEK_V4, wire.url)
response: Final = _send(gateway, _ENDPOINTS[endpoint], group, _project_key(scenario, group), tools=True)
upstream: Final = wire.drain()
assert response.status_code == 429, response.text
assert "model_per_project_itpm" in response.text, response.text
assert upstream == ()
def test_project_itpm_keeps_openai_tool_framing_for_other_models(gateway: Gateway) -> None:
chat: Final = _ENDPOINTS["chat-completions"]
with wire_server(_completion) as wire, gateway.scenario() as scenario:
group: Final = _deployment(scenario, "grok-4-1", _GROK, wire.url)
response: Final = gateway.request(
"POST",
chat.path,
{"model": group, **chat.body, "tools": [_OPENAI_TOOL]},
key=_project_key(scenario, group),
)
upstream: Final = wire.drain()
assert response.status_code == 200, response.text
assert len(upstream) == 1, upstream
_FILES: Final = 3
_READ_FILE_FUNCTION: Final[dict[str, JsonValue]] = {
"name": "read_file",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
}
_READ_FILE_TOOL: Final[dict[str, JsonValue]] = {
"name": "read_file",
"input_schema": _READ_FILE_FUNCTION["parameters"],
}
def _read_file_round(marker: str, index: int) -> tuple[JsonValue, ...]:
return (
{"role": "user", "content": f"Read file {index}."},
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": f"toolu_{index}", "name": "read_file", "input": {"path": f"{index}.txt"}}
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": f"toolu_{index}",
"content": f"<file {marker}-{index}>short file body.",
}
],
},
)
def _agent_body(group: str, marker: str) -> dict[str, JsonValue]:
rounds: Final = tuple(chain.from_iterable(_read_file_round(marker, index) for index in range(_FILES)))
return {
"model": group,
"max_tokens": 16,
"tools": [_READ_FILE_TOOL],
"messages": [*rounds, {"role": "user", "content": "Summarize what you read."}],
"context_management": {
"edits": [
{
"type": "clear_tool_uses_20250919",
"trigger": {"type": "input_tokens", "value": _deepseek_v4_tool_tokens(_READ_FILE_FUNCTION)},
"keep": {"type": "tool_uses", "value": 1},
}
]
},
}
@pytest.mark.parametrize(
("model", "files_kept"),
((_DEEPSEEK_V4, (False, False, True)), (_GROK, (True, True, True))),
ids=("deepseek-v4", "grok-4-1"),
)
def test_messages_context_management_trigger_counts_tools_the_way_the_models_chat_template_renders_them(
gateway: Gateway, model: str, files_kept: tuple[bool, ...]
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_completion) as wire, gateway.scenario() as scenario:
group: Final = _deployment(scenario, "context", model, wire.url)
response: Final = gateway.request("POST", "/v1/messages", _agent_body(group, marker))
upstream: Final = wire.drain()
assert response.status_code == 200, response.text
assert len(upstream) == 1, upstream
sent: Final = upstream[0].body.decode()
assert tuple(f"<file {marker}-{index}>" in sent for index in range(_FILES)) == files_kept

View file

@ -0,0 +1,358 @@
import asyncio
import json
import uuid
from collections.abc import Callable, Iterator, Mapping
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
from dataclasses import dataclass
from itertools import product
from pathlib import Path
from typing import Final
import anthropic
import httpx
import openai
import pytest
import yaml
from integration._support.client import GATEWAY_LIMITS, Gateway, eventually, gateway_from_environment, object_value
from integration._support.database import read_rows
from integration._support.process import owned_proxy_process
from integration._support.redis_process import OwnedRedis, owned_redis
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue
from redis import Redis
pytestmark: Final = pytest.mark.timeout(300)
COOLDOWN_SECONDS: Final = 120
NO_DEPLOYMENTS: Final = "No deployments available"
ROW_SECONDS: Final = 70
CHAT: Final = "/v1/chat/completions"
MESSAGES: Final = "/v1/messages"
RESPONSES: Final = "/v1/responses"
FAILURE_ROW: Final = (
"SELECT status, model_group, model_id, api_base, metadata::jsonb -> 'error_information' AS error "
'FROM "LiteLLM_SpendLogs" WHERE request_id = %s'
)
ENDPOINT_CELLS: Final = tuple((path, stream) for path in (CHAT, MESSAGES, RESPONSES) for stream in (False, True))
ENDPOINT_GROUPS: Final = tuple(
f"{path.rsplit('/', 1)[-1]}-{'stream' if stream else 'json'}" for path, stream in ENDPOINT_CELLS
)
SDK_CELLS: Final = ("openai-sync", "openai-async-stream", "anthropic-sync")
TAGGED: Final = "tagged"
REGIONAL: Final = "regional"
GROUPS: Final = (*ENDPOINT_GROUPS, *SDK_CELLS, "neighbour", "scoped", "burst", TAGGED, REGIONAL)
LANES: Final = ("a", "b")
LANE_PARAMS: Final[Mapping[tuple[str, str], Mapping[str, JsonValue]]] = {
(TAGGED, "a"): {"tags": ["tenant-a"]},
(TAGGED, "b"): {"tags": ["default"]},
(REGIONAL, "a"): {"region_name": "eu"},
(REGIONAL, "b"): {"region_name": "us"},
}
TENANT_A: Final[Mapping[str, JsonValue]] = {"metadata": {"tags": ["tenant-a"]}}
UNTAGGED: Final[Mapping[str, JsonValue]] = {}
@dataclass(frozen=True, slots=True)
class Rig:
gateway: Gateway
cache: OwnedRedis
prefix: str
wire: Wire
def group(self, name: str) -> str:
return f"{self.prefix}-{name}"
def deployments(self, name: str) -> Mapping[str, str]:
return {f"{self.group(name)}-{lane}": f"{self.wire.url}/{self.group(name)}/{lane}/v1" for lane in LANES}
@dataclass(frozen=True, slots=True)
class Outcome:
status: int
detail: str
call_id: str
def is_model_probe(request: Request) -> bool:
return (request.method, request.target) == ("GET", "/v1/models")
def rate_limited(request: Request) -> Reply:
if is_model_probe(request):
return Reply(body=b'{"object": "list", "data": []}')
body: Final = {"error": {"message": "scripted 429", "type": "rate_limit_error", "code": "rate_limit_exceeded"}}
return Reply(status=429, body=json.dumps(body).encode())
def cooldown_config(directory: Path, cache: OwnedRedis, prefix: str, wire: Wire) -> Path:
base: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
assert isinstance(base, dict)
model_list: Final = [
{
"model_name": f"{prefix}-{name}",
"litellm_params": {
"model": "openai/gpt-5.4",
"api_base": f"{wire.url}/{prefix}-{name}/{lane}/v1",
"api_key": "synthetic-openai-key",
**LANE_PARAMS.get((name, lane), {}),
},
"model_info": {
"id": f"{prefix}-{name}-{lane}",
**({"enable_tag_filtering": True} if name == TAGGED else {}),
},
}
for name, lane in product(GROUPS, LANES)
]
config: Final = {
**base,
"model_list": model_list,
"router_settings": {
"num_retries": 0,
"allowed_fails": 0,
"cooldown_time": COOLDOWN_SECONDS,
"enable_pre_call_checks": True,
"redis_host": cache.host,
"redis_port": cache.port,
},
}
path: Final = directory / f"cooldown-{prefix}.yaml"
path.write_text(yaml.safe_dump(config))
return path
@pytest.fixture(scope="module")
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
directory: Final = tmp_path_factory.mktemp("router-cooldown-failure-row")
prefix: Final = f"cooldown-{uuid.uuid4().hex[:8]}"
with ExitStack() as stack:
upstream: Final = stack.enter_context(gateway_from_environment())
cache: Final = stack.enter_context(owned_redis(directory))
wire: Final = stack.enter_context(wire_server(rate_limited))
config: Final = cooldown_config(directory, cache, prefix, wire)
owned: Final = stack.enter_context(owned_proxy_process(upstream, directory, {}, config=config, workers=2))
yield Rig(owned.gateway, cache, prefix, wire)
def cooled(cache: OwnedRedis, deployment_id: str) -> bool:
with Redis(host=cache.host, port=cache.port) as client:
return int(client.exists(f"deployment:{deployment_id}:cooldown")) == 1
def request_body(path: str, group: str, stream: bool) -> dict[str, JsonValue]:
content: Final = f"cooldown row {uuid.uuid4().hex}"
match path:
case "/v1/messages":
return {
"model": group,
"max_tokens": 8,
"messages": [{"role": "user", "content": content}],
"stream": stream,
}
case "/v1/responses":
return {"model": group, "input": content, "stream": stream}
case _:
return {"model": group, "messages": [{"role": "user", "content": content}], "stream": stream}
def send(gateway: Gateway, path: str, group: str, stream: bool = False) -> Outcome:
response: Final = gateway.request("POST", path, request_body(path, group, stream), headers={"Connection": "close"})
return Outcome(response.status_code, response.text, response.headers["x-litellm-call-id"])
def send_chat(rig: Rig, name: str, extra: Mapping[str, JsonValue]) -> Outcome:
body: Final = {**request_body(CHAT, rig.group(name), False), **extra}
response: Final = rig.gateway.request("POST", CHAT, body, headers={"Connection": "close"})
return Outcome(response.status_code, response.text, response.headers["x-litellm-call-id"])
def failure_row(call_id: str) -> dict[str, JsonValue]:
rows: Final = eventually(
lambda: read_rows(FAILURE_ROW, (call_id,)), lambda found: len(found) == 1, seconds=ROW_SECONDS
)
assert rows[0]["status"] == "failure", rows
return rows[0]
def until_router_refusal(attempt: Callable[[], Outcome], tries: int = 10) -> tuple[Outcome, ...]:
def walk() -> Iterator[Outcome]:
for _ in range(tries):
outcome: Final = attempt()
yield outcome
if NO_DEPLOYMENTS in outcome.detail:
return
history: Final = tuple(walk())
assert NO_DEPLOYMENTS in history[-1].detail, [(o.status, o.detail[:200]) for o in history]
return history
def cool_group(rig: Rig, name: str) -> tuple[Outcome, ...]:
history: Final = until_router_refusal(lambda: send(rig.gateway, CHAT, rig.group(name)))
assert all(cooled(rig.cache, identity) for identity in rig.deployments(name)), history
return history
def expected_cooldown(deployments: Mapping[str, str]) -> list[JsonValue]:
return [{"model_id": identity, "api_base": deployments[identity]} for identity in sorted(deployments)]
def cooldown_entries(error: Mapping[str, JsonValue]) -> list[JsonValue] | None:
entries: Final = error.get("error_cooldown_deployments")
if not isinstance(entries, list):
return None
return sorted(entries, key=lambda entry: str(object_value(entry)["model_id"]))
def assert_refusal_row(call_id: str, deployments: Mapping[str, str]) -> Mapping[str, JsonValue]:
row: Final = failure_row(call_id)
error: Final = object_value(row["error"])
assert (error["error_class"], error["error_code"]) == ("RouterRateLimitError", "429"), row
assert cooldown_entries(error) == expected_cooldown(deployments), error.get("error_message")
assert (row["model_id"], row["api_base"]) == ("", ""), row
return error
def provider_row(call_id: str) -> tuple[str, str, str, JsonValue]:
row: Final = failure_row(call_id)
error: Final = object_value(row["error"])
return str(row["model_id"]), str(row["api_base"]), str(error["error_code"]), error.get("error_cooldown_deployments")
def assert_provider_rows(history: tuple[Outcome, ...], deployments: Mapping[str, str]) -> None:
provider: Final = history[:-1]
assert all(outcome.status == 429 and NO_DEPLOYMENTS not in outcome.detail for outcome in provider), [
(o.status, o.detail[:200]) for o in provider
]
rows: Final = tuple(provider_row(outcome.call_id) for outcome in provider)
assert {identity for identity, _, _, _ in rows} == set(deployments), rows
assert all(base.startswith(deployments[identity]) for identity, base, _, _ in rows), rows
assert all((code, cooldown) == ("429", None) for _, _, code, cooldown in rows), rows
@pytest.mark.parametrize(("path", "stream"), ENDPOINT_CELLS, ids=ENDPOINT_GROUPS)
def test_all_cooled_refusal_row_records_429_and_every_cooled_deployment(rig: Rig, path: str, stream: bool) -> None:
name: Final = ENDPOINT_GROUPS[ENDPOINT_CELLS.index((path, stream))]
history: Final = until_router_refusal(lambda: send(rig.gateway, path, rig.group(name), stream))
refusal: Final = history[-1]
assert refusal.status == 429, (refusal.status, refusal.detail[:300])
assert_refusal_row(refusal.call_id, rig.deployments(name))
assert_provider_rows(history, rig.deployments(name))
def openai_chat(rig: Rig, group: str) -> Outcome:
client: Final = openai.OpenAI(base_url=f"{rig.gateway.client.base_url}/v1", api_key=rig.gateway.key, max_retries=0)
with pytest.raises(openai.RateLimitError) as raised:
client.chat.completions.create(model=group, messages=[{"role": "user", "content": uuid.uuid4().hex}])
return Outcome(429, raised.value.message, raised.value.response.headers["x-litellm-call-id"])
def openai_async_stream(rig: Rig, group: str) -> Outcome:
async def call() -> Outcome:
client: Final = openai.AsyncOpenAI(
base_url=f"{rig.gateway.client.base_url}/v1", api_key=rig.gateway.key, max_retries=0
)
try:
with pytest.raises(openai.RateLimitError) as raised:
await client.chat.completions.create(
model=group, messages=[{"role": "user", "content": uuid.uuid4().hex}], stream=True
)
return Outcome(429, raised.value.message, raised.value.response.headers["x-litellm-call-id"])
finally:
await client.close()
return asyncio.run(call())
def anthropic_message(rig: Rig, group: str) -> Outcome:
client: Final = anthropic.Anthropic(
base_url=str(rig.gateway.client.base_url), api_key=rig.gateway.key, max_retries=0
)
with pytest.raises(anthropic.RateLimitError) as raised:
client.messages.create(model=group, max_tokens=8, messages=[{"role": "user", "content": uuid.uuid4().hex}])
return Outcome(429, raised.value.message, raised.value.response.headers["x-litellm-call-id"])
SDK_CLIENTS: Final = {
"openai-sync": openai_chat,
"openai-async-stream": openai_async_stream,
"anthropic-sync": anthropic_message,
}
@pytest.mark.parametrize("client", SDK_CELLS)
def test_sdk_clients_get_429_and_the_row_names_the_cooled_deployments(rig: Rig, client: str) -> None:
history: Final = until_router_refusal(lambda: SDK_CLIENTS[client](rig, rig.group(client)))
assert_refusal_row(history[-1].call_id, rig.deployments(client))
assert_provider_rows(history, rig.deployments(client))
def test_the_row_lists_only_the_requested_groups_deployments_while_another_group_cools(rig: Rig) -> None:
cool_group(rig, "neighbour")
cool_group(rig, "scoped")
refusal: Final = eventually(
lambda: send(rig.gateway, CHAT, rig.group("scoped")),
lambda outcome: (
NO_DEPLOYMENTS in outcome.detail
and all(identity in outcome.detail for identity in rig.deployments("neighbour"))
),
)
assert_refusal_row(refusal.call_id, rig.deployments("scoped"))
def test_a_concurrent_burst_on_a_cooled_group_writes_one_complete_row_per_refusal(rig: Rig) -> None:
cool_group(rig, "burst")
rig.wire.drain()
calls: Final = tuple(ENDPOINT_CELLS[index % len(ENDPOINT_CELLS)] for index in range(30))
with (
httpx.Client(
base_url=rig.gateway.client.base_url, timeout=60, trust_env=False, limits=GATEWAY_LIMITS
) as client,
ThreadPoolExecutor(max_workers=30) as pool,
):
patient: Final = Gateway(client, rig.gateway.key, rig.gateway.upstream_url)
outcomes: Final = tuple(pool.map(lambda call: send(patient, call[0], rig.group("burst"), call[1]), calls))
assert all(outcome.status == 429 for outcome in outcomes), [(o.status, o.detail[:200]) for o in outcomes]
assert len({outcome.call_id for outcome in outcomes}) == len(outcomes), outcomes
refusals: Final = tuple(outcome for outcome in outcomes if NO_DEPLOYMENTS in outcome.detail)
reached: Final = tuple(outcome for outcome in outcomes if NO_DEPLOYMENTS not in outcome.detail)
assert refusals, [(o.status, o.detail[:200]) for o in outcomes]
assert len(tuple(request for request in rig.wire.drain() if not is_model_probe(request))) == len(reached)
for outcome in refusals:
assert_refusal_row(outcome.call_id, rig.deployments("burst"))
for outcome in reached:
identity, api_base, code, cooldown = provider_row(outcome.call_id)
assert api_base.startswith(rig.deployments("burst")[identity]), (identity, api_base)
assert (code, cooldown) == ("429", None), outcome
def cool_lane(rig: Rig, name: str, lane: str, extra: Mapping[str, JsonValue]) -> None:
identity: Final = f"{rig.group(name)}-{lane}"
outcomes: Final = eventually(lambda: send_chat(rig, name, extra), lambda _: cooled(rig.cache, identity))
assert NO_DEPLOYMENTS not in outcomes.detail, outcomes.detail[:300]
def router_refusal(rig: Rig, name: str, extra: Mapping[str, JsonValue]) -> Outcome:
refusal: Final = eventually(lambda: send_chat(rig, name, extra), lambda outcome: NO_DEPLOYMENTS in outcome.detail)
assert refusal.status == 429, (refusal.status, refusal.detail[:300])
return refusal
def test_a_tag_routed_refusal_row_names_only_the_cooled_deployments_its_tags_reach(rig: Rig) -> None:
deployments: Final = rig.deployments(TAGGED)
tenant: Final = f"{rig.group(TAGGED)}-a"
cool_lane(rig, TAGGED, "a", TENANT_A)
cool_lane(rig, TAGGED, "b", UNTAGGED)
assert_refusal_row(router_refusal(rig, TAGGED, TENANT_A).call_id, {tenant: deployments[tenant]})
assert_refusal_row(router_refusal(rig, TAGGED, UNTAGGED).call_id, deployments)
def test_a_region_restricted_refusal_row_names_only_the_cooled_deployments_in_its_region(rig: Rig) -> None:
deployments: Final = rig.deployments(REGIONAL)
european: Final = f"{rig.group(REGIONAL)}-a"
customer: Final = f"{rig.prefix}-eu-customer"
with ExitStack() as cleanups:
rig.gateway.post("/customer/new", {"user_id": customer, "allowed_model_region": "eu"})
cleanups.callback(rig.gateway.post, "/customer/delete", {"user_ids": [customer]})
cool_group(rig, REGIONAL)
assert_refusal_row(router_refusal(rig, REGIONAL, {"user": customer}).call_id, {european: deployments[european]})

View file

@ -11,7 +11,7 @@
"user": "",
"team_id": "",
"organization_id": "",
"metadata": "{\"actor_agent_id\": null, \"target_agent_id\": null, \"billing_agent_id\": null, \"agent_execution_mode\": null, \"verified_human_user_id\": null, \"used_client_oauth_token\": null, \"applied_guardrails\": [], \"attempted_fallbacks\": null, \"original_model_group\": null, \"batch_models\": null, \"batch_successful_requests\": null, \"batch_failed_requests\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"routing_decision\": null, \"internal_call_origin\": null, \"litellm_roi_estimator\": false, \"router_metadata\": null, \"autorouter_savings_estimate\": null, \"autorouter_baseline_observation\": null, \"azure_spillover\": null, \"guardrail_information\": null, \"compression_savings\": null, \"litellm_gateway_injected_cache\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_user_id\": null, \"user_api_key_user_email\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"user_agent\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null}",
"metadata": "{\"actor_agent_id\": null, \"target_agent_id\": null, \"billing_agent_id\": null, \"agent_execution_mode\": null, \"verified_human_user_id\": null, \"used_client_oauth_token\": null, \"applied_guardrails\": [], \"attempted_fallbacks\": null, \"original_model_group\": null, \"batch_models\": null, \"batch_successful_requests\": null, \"batch_failed_requests\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"routing_decision\": null, \"internal_call_origin\": null, \"litellm_roi_estimator\": false, \"router_metadata\": null, \"autorouter_savings_estimate\": null, \"autorouter_baseline_observation\": null, \"azure_spillover\": null, \"guardrail_information\": null, \"compression_savings\": null, \"litellm_gateway_injected_cache\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_user_id\": null, \"user_api_key_user_email\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"user_agent\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null, \"kubernetes_pod_routing\": null}",
"cache_key": "Cache OFF",
"spend": 0.00022500000000000002,
"total_tokens": 30,

View file

@ -51,7 +51,7 @@ from litellm.proxy import proxy_server
from litellm.proxy._types import SpanAttributes
from litellm.types.services import ServiceLoggerPayload, ServiceTypes
from collections.abc import AsyncIterator
from litellm.constants import LOGGING_WORKER_MAX_TIME_PER_COROUTINE
from litellm.constants import KUBERNETES_POD_ROUTING_KEY, LOGGING_WORKER_MAX_TIME_PER_COROUTINE
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.types.utils import ChatCompletionMessageToolCall, Function
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
@ -363,6 +363,33 @@ def test_v1_routing_diagnostics_add_scalar_attributes_and_preserve_legacy_metada
assert finished.attributes["metadata.routing_decision"] == str(decision)
def test_v1_pod_routing_none_matches_other_none_metadata_attributes() -> None:
exporter: Final = InMemorySpanExporter()
provider: Final = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
otel: Final = OpenTelemetry(tracer_provider=provider)
with provider.get_tracer(__name__).start_as_current_span("inference") as span:
otel.set_attributes(
span,
{
"model": "answer",
"standard_logging_object": {
"id": "routing-call",
"call_type": "acompletion",
"metadata": {
KUBERNETES_POD_ROUTING_KEY: None,
"other_none": None,
},
},
},
{"model": "answer", "choices": [], "usage": {}},
)
(finished,) = exporter.get_finished_spans()
routing_attribute: Final = finished.attributes["metadata.kubernetes_pod_routing"]
assert routing_attribute == finished.attributes["metadata.other_none"]
assert routing_attribute != "null"
@pytest.mark.parametrize("decision", ["custom", 42, ["custom"]])
def test_v1_malformed_routing_metadata_preserves_inference_attributes(decision: object) -> None:
exporter: Final = InMemorySpanExporter()

View file

@ -0,0 +1,31 @@
from typing import Final
import pytest
from litellm.litellm_core_utils.prompt_templates.chat_template_tools import chat_template_tool_section
_DESCRIBED_FUNCTION: Final = {"name": "f", "description": "é", "parameters": {}}
_BARE_FUNCTION: Final = {"name": "g", "parameters": {}}
# Tool schemas as rendered by render_tools in encoding/encoding_dsv4.py (deepseek-ai/DeepSeek-V4-Flash) and by
# chat_template.jinja (Qwen/Qwen3.5-122B-A10B), both read on Hugging Face on 2026-10-17
@pytest.mark.parametrize(
("model", "rendered"),
[
(
"DeepSeek-V4-Flash-0731",
'{"name": "f", "description": "é", "parameters": {}}\n{"name": "g", "parameters": {}}',
),
(
"databricks-qwen35-122b-a10b",
'\n{"type": "function", "function": {"name": "f", "description": "é", "parameters": {}}}'
'\n{"type": "function", "function": {"name": "g", "parameters": {}}}',
),
],
)
def test_chat_template_tool_section_renders_schemas_like_the_models_template(model: str, rendered: str):
section: Final = chat_template_tool_section(model)
assert section is not None
assert section.render_tool_schemas((_DESCRIBED_FUNCTION, _BARE_FUNCTION)) == rendered

View file

@ -30,7 +30,12 @@ from litellm._internal_context import in_post_response_phase
from litellm._logging import session_id_var, trace_id_var, verbose_logger
from litellm._service_logger import ServiceLogging
from litellm.caching.caching import DualCache
from litellm.constants import LOGGING_WORKER_MAX_TIME_PER_COROUTINE, REDACTED_BY_LITELLM, SENTRY_PII_DENYLIST
from litellm.constants import (
KUBERNETES_POD_ROUTING_KEY,
LOGGING_WORKER_MAX_TIME_PER_COROUTINE,
REDACTED_BY_LITELLM,
SENTRY_PII_DENYLIST,
)
from litellm.cost_calculator import ocr_batch_cost
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils import litellm_logging
@ -4564,6 +4569,50 @@ def test_get_error_information_falls_back_to_str_when_no_message_attr():
assert result["error_class"] == "ValueError"
def test_get_error_information_records_the_cooled_deployments_of_a_router_refusal():
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.types.router import RouterRateLimitError
from litellm.types.utils import CooldownDeploymentInfo
cooled: Final = (
CooldownDeploymentInfo(model_id="refused-1", api_base="https://first.example/v1"),
CooldownDeploymentInfo(model_id="refused-2", api_base=None),
)
refusal: Final = RouterRateLimitError(
model="refused",
cooldown_time=60,
enable_pre_call_checks=False,
cooldown_list=["refused-1", "refused-2", "neighbour-1"],
model_ids=("refused-1", "refused-2"),
cooldown_deployments=cooled,
)
result: Final = StandardLoggingPayloadSetup.get_error_information(refusal)
assert (result["error_class"], result["error_code"], result["error_cooldown_deployments"]) == (
"RouterRateLimitError",
"429",
cooled,
), result
provider_429: Final = litellm.RateLimitError("scripted 429", "openai", "gpt-5.4")
assert StandardLoggingPayloadSetup.get_error_information(provider_429)["error_cooldown_deployments"] is None
def test_get_error_information_handles_a_router_refusal_subclass_that_skips_its_init():
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.types.router import RouterRateLimitError
class PluginRefusal(RouterRateLimitError):
def __init__(self, message: str) -> None:
ValueError.__init__(self, message)
result: Final = StandardLoggingPayloadSetup.get_error_information(PluginRefusal("plugin refused"))
assert (result["error_class"], result["error_code"], result["error_cooldown_deployments"]) == (
"PluginRefusal",
"429",
None,
), result
# ──────────────────────────────────────────────────────────────────────
# Tests for _get_assembled_streaming_response non-streaming early return
# ──────────────────────────────────────────────────────────────────────
@ -5713,6 +5762,97 @@ def test_get_standard_logging_object_payload_takes_used_client_oauth_token_from_
assert payload["metadata"]["used_client_oauth_token"] is expected
@pytest.mark.parametrize(
("metadata", "litellm_metadata", "expected"),
[
(
{
"kubernetes_pod_routing": {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.1",
"pod_count": 3,
"selection": "round_robin",
}
},
{},
{
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.1",
"pod_count": 3,
"selection": "round_robin",
},
),
(
{},
{
"kubernetes_pod_routing": {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.2",
"pod_count": 3,
"selection": "session_affinity",
}
},
{
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.2",
"pod_count": 3,
"selection": "session_affinity",
},
),
(
{
"kubernetes_pod_routing": {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.1",
"pod_count": 3,
"selection": "round_robin",
}
},
{
"kubernetes_pod_routing": {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.3",
"pod_count": 3,
"selection": "session_affinity_retry",
}
},
{
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.3",
"pod_count": 3,
"selection": "session_affinity_retry",
},
),
],
)
def test_standard_logging_payload_resolves_kubernetes_pod_routing_from_metadata_buckets(
logging_obj: LitellmLogging,
metadata: dict[str, object],
litellm_metadata: dict[str, object],
expected: dict[str, object],
) -> None:
from datetime import datetime
from litellm.litellm_core_utils.litellm_logging import get_standard_logging_object_payload
now: Final = datetime(2026, 1, 1)
payload: Final = get_standard_logging_object_payload(
kwargs={
"model": "gpt-4o",
"messages": [],
"litellm_params": {"metadata": metadata, "litellm_metadata": litellm_metadata},
},
init_response_obj={},
start_time=now,
end_time=now,
logging_obj=logging_obj,
status="success",
)
assert payload is not None
assert payload["metadata"][KUBERNETES_POD_ROUTING_KEY] == expected
def test_get_standard_logging_object_payload_carries_matched_access_groups(logging_obj):
"""Access groups stamped at auth time reach the logging payload, so integrations see what a request billed."""
from datetime import datetime

View file

@ -9,7 +9,7 @@ import subprocess
import sys
import threading
import time
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from concurrent.futures import Future, wait
from itertools import accumulate, chain
from pathlib import Path
@ -39,6 +39,7 @@ from litellm.litellm_core_utils.token_counter import (
offload_token_count,
)
from litellm.litellm_core_utils.token_counter import token_counter as token_counter_new
from litellm.llms.anthropic.pass_through.adapters.transformation import LiteLLMAnthropicMessagesAdapter
from tests.large_text import text
from tests.unit.litellm_core_utils.event_loop_lag import (
assert_loop_stayed_free,
@ -515,6 +516,183 @@ def test_token_counter_skips_non_mapping_tools():
assert mixed_tokens == valid_tokens
_WEATHER_FUNCTION: Final = {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string", "description": "City name"}},
"required": ["location"],
},
}
_TIME_FUNCTION: Final = {
"name": "get_time",
"description": "Get the local time for a city",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
}
_WEB_SEARCH_FUNCTION: Final = {
"name": "web_search",
"description": "Search the web",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
}
def _openai_tool(function: Mapping[str, object]) -> dict[str, object]:
return {"type": "function", "function": function}
def _anthropic_tool(function: Mapping[str, object]) -> dict[str, object]:
return {"name": function["name"], "description": function["description"], "input_schema": function["parameters"]}
_USER_MESSAGES: Final = ({"role": "user", "content": "What is the weather in Paris today?"},)
_SYSTEM_AND_USER_MESSAGES: Final = ({"role": "system", "content": "You are a helpful assistant."}, *_USER_MESSAGES)
@pytest.mark.parametrize("tool_shape", [_openai_tool, _anthropic_tool])
@pytest.mark.parametrize(
("model", "messages", "functions", "billed_prompt_tokens"),
[
# usage.prompt_tokens billed for this request by Azure AI Foundry DeepSeek-V4-Flash-0731,
# Databricks databricks-qwen35-122b-a10b and DashScope qwen3.5-plus on 2026-10-17
("DeepSeek-V4-Flash-0731", _USER_MESSAGES, (_WEATHER_FUNCTION,), 295),
("DeepSeek-V4-Flash-0731", _USER_MESSAGES, (_WEATHER_FUNCTION, _TIME_FUNCTION), 348),
("DeepSeek-V4-Flash-0731", _SYSTEM_AND_USER_MESSAGES, (_WEATHER_FUNCTION,), 300),
("databricks-qwen35-122b-a10b", _USER_MESSAGES, (_WEATHER_FUNCTION,), 289),
("databricks-qwen35-122b-a10b", _USER_MESSAGES, (_WEATHER_FUNCTION, _TIME_FUNCTION), 352),
("databricks-qwen35-122b-a10b", _SYSTEM_AND_USER_MESSAGES, (_WEATHER_FUNCTION,), 296),
("qwen3.5-plus", _USER_MESSAGES, (_WEATHER_FUNCTION,), 284),
("qwen3.5-plus", _USER_MESSAGES, (_WEATHER_FUNCTION, _TIME_FUNCTION), 342),
],
)
def test_token_counter_tools_match_billed_prompt_for_json_tool_chat_templates(
tool_shape: Callable[[Mapping[str, object]], dict[str, object]],
model: str,
messages: tuple[dict[str, str], ...],
functions: tuple[Mapping[str, object], ...],
billed_prompt_tokens: int,
):
counted: Final = token_counter_new(
model=model,
messages=list(messages),
tools=[tool_shape(function) for function in functions],
)
assert abs(counted - billed_prompt_tokens) <= billed_prompt_tokens * 0.05
@pytest.mark.parametrize(
("model", "counted_like"),
[
("DeepSeek-V4-Pro", "DeepSeek-V4-Flash-0731"),
("accounts/fireworks/models/deepseek-v4-flash", "DeepSeek-V4-Flash-0731"),
("DeepSeek-V4.1-Flash", "deepseek-v3.2"),
("accounts/fireworks/models/deepseek-v4p1-flash", "deepseek-v3.2"),
("Qwen/Qwen3.5-122B-A10B", "databricks-qwen35-122b-a10b"),
("qwen3-235b-a22b", "deepseek-v3.2"),
],
)
def test_token_counter_counts_tools_by_the_models_chat_template_family(model: str, counted_like: str):
tools: Final = [_openai_tool(_WEATHER_FUNCTION), _openai_tool(_TIME_FUNCTION)]
assert token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=tools) == token_counter_new(
model=counted_like, messages=list(_USER_MESSAGES), tools=tools
)
@pytest.mark.parametrize("model", ["DeepSeek-V4-Flash-0731", "databricks-qwen35-122b-a10b"])
@pytest.mark.parametrize(
"builtin_tool",
[
{"type": "web_search_preview"},
{"type": "web_search_20250305", "name": "web_search", "max_uses": 5},
{"name": "web_search"},
],
)
def test_token_counter_skips_tools_not_sent_as_functions_for_chat_template_families(
model: str, builtin_tool: dict[str, object]
):
weather_tool: Final = _openai_tool(_WEATHER_FUNCTION)
assert token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=[builtin_tool]) == token_counter_new(
model=model, messages=list(_USER_MESSAGES)
)
assert token_counter_new(
model=model, messages=list(_USER_MESSAGES), tools=[builtin_tool, weather_tool]
) == token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=[weather_tool])
def _responses_tool(function: Mapping[str, object]) -> dict[str, object]:
return {"type": "function", **function}
def _gemini_tool(function: Mapping[str, object]) -> dict[str, object]:
return {"function_declarations": [function]}
@pytest.mark.parametrize("model", ["DeepSeek-V4-Flash-0731", "databricks-qwen35-122b-a10b"])
@pytest.mark.parametrize("function", [_WEATHER_FUNCTION, _WEB_SEARCH_FUNCTION])
@pytest.mark.parametrize("tool_shape", [_responses_tool, _gemini_tool])
def test_token_counter_counts_function_tools_like_chat_completions_tools_for_chat_template_families(
model: str, function: Mapping[str, object], tool_shape: Callable[[Mapping[str, object]], dict[str, object]]
):
assert token_counter_new(
model=model, messages=list(_USER_MESSAGES), tools=[tool_shape(function)]
) == token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=[_openai_tool(function)])
# Azure AI Foundry DeepSeek-V4-Flash-0731 billed 295 prompt tokens for the weather tool with each of
# these tool_choice values on 2026-10-17, the same as without one
@pytest.mark.parametrize("tool_choice", ["none", "required", {"type": "function", "function": {"name": "get_weather"}}])
def test_token_counter_tool_choice_adds_nothing_for_deepseek_v4(tool_choice: object):
tools: Final = [_openai_tool(_WEATHER_FUNCTION)]
assert token_counter_new(
model="DeepSeek-V4-Flash-0731", messages=list(_USER_MESSAGES), tools=tools, tool_choice=tool_choice
) == token_counter_new(model="DeepSeek-V4-Flash-0731", messages=list(_USER_MESSAGES), tools=tools)
@pytest.mark.parametrize("model", ["DeepSeek-V4-Flash-0731", "databricks-qwen35-122b-a10b"])
@pytest.mark.parametrize(
"anthropic_tool",
[
{"name": "get_weather", "input_schema": _WEATHER_FUNCTION["parameters"]},
_anthropic_tool(_WEATHER_FUNCTION),
{**_anthropic_tool(_WEATHER_FUNCTION), "strict": True},
{**_anthropic_tool(_WEATHER_FUNCTION), "defer_loading": True, "input_examples": [{"location": "Paris"}]},
_anthropic_tool(_WEB_SEARCH_FUNCTION),
],
)
def test_token_counter_counts_anthropic_tools_as_sent_to_chat_template_families(
model: str, anthropic_tool: dict[str, object]
):
sent_tools, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_tools_to_openai([anthropic_tool]) # pyright: ignore[reportArgumentType] # raw Anthropic tool dict
assert token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=[anthropic_tool]) == token_counter_new(
model=model, messages=list(_USER_MESSAGES), tools=sent_tools
)
# Qwen3.5's chat_template.jinja opens a system turn for the tool schemas when the request has none, 5 tokens that a
# request with a system message already pays, while DeepSeek V4's encoding_dsv4.py appends them to the first turn
# either way (both read on Hugging Face on 2026-10-17)
@pytest.mark.parametrize(
("model", "implicit_system_turn_tokens"), [("DeepSeek-V4-Flash-0731", 0), ("databricks-qwen35-122b-a10b", 5)]
)
def test_token_counter_counts_the_tools_system_turn_only_without_a_system_message(
model: str, implicit_system_turn_tokens: int
):
tools: Final = [_openai_tool(_WEATHER_FUNCTION)]
system_cost_with_tools: Final = token_counter_new(
model=model, messages=list(_SYSTEM_AND_USER_MESSAGES), tools=tools
) - token_counter_new(model=model, messages=list(_USER_MESSAGES), tools=tools)
system_cost_without_tools: Final = token_counter_new(
model=model, messages=list(_SYSTEM_AND_USER_MESSAGES)
) - token_counter_new(model=model, messages=list(_USER_MESSAGES))
assert system_cost_with_tools == system_cost_without_tools - implicit_system_turn_tokens
class NeedsToleranceUpdateError(Exception):
"""Custom exception to mark tests that have improved"""
@ -1770,14 +1948,17 @@ _THRESHOLD_TEST_TOOLS: Final = [
]
def test_messages_reach_token_count_agrees_with_token_counter_at_every_threshold() -> None:
@pytest.mark.parametrize(
"model", ["claude-3-5-sonnet-20240620", "DeepSeek-V4-Flash-0731", "databricks-qwen35-122b-a10b"]
)
def test_messages_reach_token_count_agrees_with_token_counter_at_every_threshold(model: str) -> None:
"""The threshold check is the same arithmetic as token_counter(...) >= threshold, including the
tools and system-message adjustments, so the boundary values must agree exactly."""
from litellm.litellm_core_utils.token_counter import messages_reach_token_count
messages = _threshold_test_messages(turns=12)
total = token_counter_new(
model="claude-3-5-sonnet-20240620",
model=model,
messages=messages,
tools=_THRESHOLD_TEST_TOOLS,
use_default_image_token_count=True,
@ -1785,7 +1966,7 @@ def test_messages_reach_token_count_agrees_with_token_counter_at_every_threshold
assert total > 100
for threshold in (0, 1, total - 1, total, total + 1, 10 * total):
assert messages_reach_token_count(
model="claude-3-5-sonnet-20240620",
model=model,
messages=messages,
threshold=threshold,
tools=_THRESHOLD_TEST_TOOLS,

View file

@ -321,6 +321,23 @@ class TestTheNormalizedTierIsTheTierSent:
):
assert _reasoning_effort_sent(model, provider, effort) == expected
@pytest.mark.parametrize(
"model, provider",
[
("gpt-5.5", "azure_ai"),
("azure_ai/gpt-5.5", "azure_ai"),
("gpt-5.6", "azure"),
("us/gpt-5.6", "azure"),
],
)
def test_an_azure_hosted_deployment_without_the_flag_drops_to_the_tier_it_accepts(
self, local_model_cost_map, model, provider
):
"""Chat Completions answers ``max`` on gpt-5.6 with a 400 naming ``none`` through ``xhigh``
(OpenAI, 2026-10-01) and none of these Azure rows declares the level, so the adapter sends
the tier below instead of the rejected one."""
assert _reasoning_effort_sent(model, provider, "max") == "xhigh"
@pytest.mark.parametrize(
"model, provider",
[
@ -328,13 +345,16 @@ class TestTheNormalizedTierIsTheTierSent:
("azure_ai/gpt-6-astra", "azure_ai"),
("gpt-6-astra", "azure"),
("us/gpt-6-astra", "azure"),
("eu/gpt-6-astra", "azure"),
],
)
def test_an_azure_hosted_astra_deployment_drops_to_the_tier_it_accepts(
def test_an_azure_hosted_astra_deployment_drops_to_the_tier_its_chat_route_accepts(
self, local_model_cost_map: None, model: str, provider: str
) -> None:
"""The deployment answers ``max`` with a 400 naming ``none`` through ``xhigh``, so the rows
say so and the adapter sends the tier below instead of the rejected one."""
"""A Foundry gpt-6-astra answers a Chat Completions ``max`` with a 400 naming low through
xhigh while honoring the same level on the Responses route (2026-10-01). The row's flag is
route-blind, so it keeps max off and the adapter, which cannot know which route the call
takes, sends the tier below instead of the level chat rejects."""
assert _reasoning_effort_sent(model, provider, "max") == "xhigh"
def test_the_openai_hosted_twin_still_sends_max(self, local_model_cost_map):

View file

@ -1,3 +1,4 @@
from collections.abc import Mapping
from typing import Final
import pytest
@ -369,17 +370,22 @@ def test_azure_gpt_6_astra_takes_the_reasoning_series_request_shape():
model="gpt-6-astra",
custom_llm_provider="azure",
max_tokens=100,
reasoning_effort="max",
reasoning_effort="xhigh",
)
assert params["max_completion_tokens"] == 100
assert "max_tokens" not in params
assert params["reasoning_effort"] == "max"
assert params["reasoning_effort"] == "xhigh"
@pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra"])
def test_azure_gpt6_astra_reasoning_effort_none_unlocks_temperature(config: AzureOpenAIGPT5Config, model: str):
"""Foundry's gpt-6-astra accepts reasoning_effort='none' and, only then, a non-default
temperature (verified live against a Foundry deployment), unlike OpenAI's gpt-6-astra."""
@pytest.mark.parametrize("model", ["azure/gpt-6-sol", "azure/us/gpt-6-sol"])
def test_azure_reasoning_effort_none_unlocks_temperature_off_the_azure_row(
config: AzureOpenAIGPT5Config, monkeypatch: pytest.MonkeyPatch, model: str
):
"""A deployment whose azure/ row takes none accepts a non-default temperature with it, read off
that row even when OpenAI's twin row says the model refuses none."""
monkeypatch.setitem(
litellm.model_cost, "gpt-6-sol", {**litellm.model_cost["gpt-6-sol"], "supports_none_reasoning_effort": False}
)
params = config.map_openai_params(
non_default_params={"temperature": 0.2, "reasoning_effort": "none"},
optional_params={},
@ -391,14 +397,127 @@ def test_azure_gpt6_astra_reasoning_effort_none_unlocks_temperature(config: Azur
assert params["reasoning_effort"] == "none"
@pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra"])
def test_azure_gpt6_astra_rejects_reasoning_effort_minimal(config: AzureOpenAIGPT5Config, model: str):
"""Foundry's gpt-6-astra lists none, low, medium, high, xhigh and max but not minimal."""
with pytest.raises(litellm.utils.UnsupportedParamsError):
config.map_openai_params(
non_default_params={"reasoning_effort": "minimal"},
def _azure_chat_row_forwards(row: Mapping[str, object], level: str) -> bool:
"""Azure chat polarity: max is never gated, xhigh and none need an explicit true, every other
level only has to not be false."""
if level == "max":
return True
flag: Final = row.get(f"supports_{level}_reasoning_effort")
return flag is True if level in ("xhigh", "none") else flag is not False
@pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra", "azure/eu/gpt-6-astra"])
@pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"])
def test_azure_gpt6_astra_forwards_exactly_the_effort_levels_its_map_row_allows(
config: AzureOpenAIGPT5Config, model: str, level: str
):
"""The azure/ row is the whole contract for a Foundry deployment. A live Foundry gpt-6-astra
(model gpt-6-astra-2026-09-03) answered reasoning_effort none with a 400 naming low, medium,
high and xhigh on 2026-10-01, so its row turns none and minimal off. The row keeps max off too,
since chat refuses it, but no gate reads that flag: the Responses route honors max, so chat
forwards it for the bridge and a plain chat call gets the provider's own 400."""
forwarded: Final = _azure_chat_row_forwards(litellm.model_cost[model], level)
dropped: Final = config.map_openai_params(
non_default_params={"reasoning_effort": level},
optional_params={},
model=model,
drop_params=True,
api_version="2025-04-01-preview",
)
assert ("reasoning_effort" in dropped) is forwarded
if forwarded:
kept: Final = config.map_openai_params(
non_default_params={"reasoning_effort": level},
optional_params={},
model=model,
drop_params=False,
api_version="2025-04-01-preview",
)
assert kept["reasoning_effort"] == level
else:
with pytest.raises(litellm.utils.UnsupportedParamsError):
config.map_openai_params(
non_default_params={"reasoning_effort": level},
optional_params={},
model=model,
drop_params=False,
api_version="2025-04-01-preview",
)
@pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra", "azure/eu/gpt-6-astra"])
def test_azure_gpt6_astra_row_turns_none_and_minimal_off(model: str):
"""Pinned on purpose so the row-derived test above cannot go vacuous: a Foundry gpt-6-astra
deployment refused none on chat and on Responses on 2026-10-01 (400 unsupported_value naming
low, medium, high, xhigh), and minimal the same way; max it refused on chat while honoring it
on Responses, so the route-blind flag stays off. When Azure adds a level, flip the row and
this assertion together."""
row: Final = litellm.model_cost[model]
assert row["supports_none_reasoning_effort"] is False
assert row["supports_minimal_reasoning_effort"] is False
assert row["supports_max_reasoning_effort"] is False
def test_azure_chat_gate_reads_the_foundry_row_when_azure_ai_prefix_survives_provider_remap(
config: AzureOpenAIGPT5Config, monkeypatch: pytest.MonkeyPatch
):
"""A Foundry deployment on an OpenAI-v1 host is re-routed to the azure provider with its
azure_ai/ prefix intact, and this config then gates it. The rows are made to disagree on low,
a level neither real row flags, so the prefixed name has to read azure_ai/gpt-6-sol while the
bare deployment name keeps reading azure/gpt-6-sol."""
monkeypatch.setitem(
litellm.model_cost,
"azure_ai/gpt-6-sol",
{**litellm.model_cost["azure_ai/gpt-6-sol"], "supports_low_reasoning_effort": False},
)
monkeypatch.setitem(
litellm.model_cost,
"azure/gpt-6-sol",
{**litellm.model_cost["azure/gpt-6-sol"], "supports_low_reasoning_effort": True},
)
prefixed: Final = config.map_openai_params(
non_default_params={"reasoning_effort": "low"},
optional_params={},
model="azure_ai/gpt-6-sol",
drop_params=True,
api_version="2025-04-01-preview",
)
assert "reasoning_effort" not in prefixed
bare: Final = config.map_openai_params(
non_default_params={"reasoning_effort": "low"},
optional_params={},
model="gpt-6-sol",
drop_params=True,
api_version="2025-04-01-preview",
)
assert bare["reasoning_effort"] == "low"
def test_azure_chat_gate_falls_back_to_the_azure_row_for_an_azure_ai_name_the_map_lacks(
config: AzureOpenAIGPT5Config, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.delitem(litellm.model_cost, "azure_ai/gpt-6-sol")
monkeypatch.setitem(
litellm.model_cost,
"azure/gpt-6-sol",
{**litellm.model_cost["azure/gpt-6-sol"], "supports_low_reasoning_effort": False},
)
dropped: Final = config.map_openai_params(
non_default_params={"reasoning_effort": "low"},
optional_params={},
model="azure_ai/gpt-6-sol",
drop_params=True,
api_version="2025-04-01-preview",
)
assert "reasoning_effort" not in dropped
@pytest.mark.parametrize("key", ["azure/gpt-6.1-sol", "azure/gpt-6.1-sol-2026-09-29", "azure_ai/gpt-6.1-sol"])
def test_azure_gpt61_sol_rows_turn_none_off(key: str):
"""Pinned on purpose, as the astra row above: a Foundry gpt-6.1-sol deployment (model
gpt-6.1-sol-2026-09-29) refused none on chat and on Responses on 2026-10-02 (400 unsupported_value
naming low, medium, high, xhigh, and max on Responses), and minimal the same way, while these rows
still said none was on. When Azure adds a level, flip the row and this assertion together."""
row: Final = litellm.model_cost[key]
assert row["supports_none_reasoning_effort"] is False
assert row["supports_minimal_reasoning_effort"] is False

View file

@ -727,16 +727,21 @@ def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> None:
litellm.add_known_models(model_cost_map=litellm.model_cost)
def test_azure_responses_gpt6_astra_reasoning_effort_none_unlocks_temperature(local_model_cost_map: None):
"""Foundry's gpt-6-astra accepts reasoning.effort='none' with a non-default temperature
while OpenAI's gpt-6-astra does not, so the gate must read the azure/ cost-map entry
for the bare deployment name rather than OpenAI's."""
def test_azure_responses_reasoning_effort_none_unlocks_temperature_off_the_azure_row(
local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
):
"""The gate reads the azure/ cost-map entry for the bare deployment name rather than OpenAI's
twin: with OpenAI's gpt-6-sol row turned off for none, azure/gpt-6-sol's row still lets none
through and, only then, a non-default temperature."""
monkeypatch.setitem(
litellm.model_cost, "gpt-6-sol", {**litellm.model_cost["gpt-6-sol"], "supports_none_reasoning_effort": False}
)
params = AzureOpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=ResponsesAPIOptionalRequestParams(
temperature=0.2,
reasoning={"effort": "none"},
),
model="gpt-6-astra",
model="gpt-6-sol",
drop_params=False,
)
assert params["temperature"] == 0.2
@ -755,6 +760,116 @@ def test_azure_responses_gpt6_astra_rejects_temperature_while_reasoning(local_mo
)
@pytest.mark.parametrize("model", ["gpt-6-astra", "gpt-6.1-sol"])
@pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"])
def test_azure_responses_gpt6_effort_levels_follow_the_azure_row(local_model_cost_map: None, model: str, level: str):
"""A bare deployment name reads the azure/ row, opt-out for every level but max, so the none
and minimal Foundry refuses on this route (live 400s on 2026-10-01 for gpt-6-astra and 2026-10-02
for gpt-6.1-sol) are dropped or refused here while the max it honors goes through whatever the
route-blind flag says."""
forwarded = (
level == "max" or litellm.model_cost[f"azure/{model}"].get(f"supports_{level}_reasoning_effort") is not False
)
dropped = AzureOpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": level}),
model=model,
drop_params=True,
)
assert ("reasoning" in dropped) is forwarded
if forwarded:
kept = AzureOpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": level}),
model=model,
drop_params=False,
)
assert kept["reasoning"] == {"effort": level}
else:
with pytest.raises(litellm.UnsupportedParamsError):
AzureOpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": level}),
model=model,
drop_params=False,
)
def test_azure_gpt6_astra_keeps_max_on_this_route_whatever_its_row_says(local_model_cost_map: None):
"""Pinned on purpose: the row-derived test above exempts max by hand, so this one has to fail
if the gate ever reads the flag. A Foundry gpt-6-astra deployment answered reasoning.effort max
through /v1/responses with 200, the effort echoed back and reasoning tokens billed, on
2026-10-01, while chat refuses the same level, as OpenAI's chat does. The row keeps max off for
the adapter and the picker, and this gate still forwards it even under drop_params."""
assert litellm.model_cost["azure/gpt-6-astra"]["supports_max_reasoning_effort"] is False
kept = AzureOpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": "max"}),
model="gpt-6-astra",
drop_params=True,
)
assert kept["reasoning"] == {"effort": "max"}
@pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"])
def test_azure_responses_effort_gate_fires_when_azure_ai_prefix_survives_provider_remap(
local_model_cost_map: None, level: str
):
"""litellm.responses(model="azure_ai/gpt-6.1-sol", api_base=<Foundry host>) is re-routed to the
azure provider with the prefix intact, so this config sees the prefixed name. The gate used to
skip every prefixed name as another provider's pass-through, and a live Foundry gpt-6.1-sol
answered the forwarded none and minimal with 400 on 2026-10-02 even under drop_params. The
prefixed name reads the azure_ai/ row and drops or refuses exactly what that row turns off."""
forwarded = (
level == "max"
or litellm.model_cost["azure_ai/gpt-6.1-sol"].get(f"supports_{level}_reasoning_effort") is not False
)
dropped = AzureOpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=ResponsesAPIOptionalRequestParams(
max_output_tokens=60, reasoning={"effort": level}
),
model="azure_ai/gpt-6.1-sol",
drop_params=True,
)
assert ("reasoning" in dropped) is forwarded
if forwarded:
return
with pytest.raises(litellm.UnsupportedParamsError):
AzureOpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=ResponsesAPIOptionalRequestParams(
max_output_tokens=60, reasoning={"effort": level}
),
model="azure_ai/gpt-6.1-sol",
drop_params=False,
)
def test_azure_responses_prefixed_name_reads_the_foundry_row_not_the_azure_twin(
local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
):
"""The rows are made to disagree on low, a level neither real row flags, so the prefixed name
proving it reads azure_ai/gpt-6-sol cannot pass by reading azure/gpt-6-sol, which the bare
deployment name keeps reading."""
monkeypatch.setitem(
litellm.model_cost,
"azure_ai/gpt-6-sol",
{**litellm.model_cost["azure_ai/gpt-6-sol"], "supports_low_reasoning_effort": False},
)
monkeypatch.setitem(
litellm.model_cost,
"azure/gpt-6-sol",
{**litellm.model_cost["azure/gpt-6-sol"], "supports_low_reasoning_effort": True},
)
prefixed = AzureOpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": "low"}),
model="azure_ai/gpt-6-sol",
drop_params=True,
)
assert "reasoning" not in prefixed
bare = AzureOpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=ResponsesAPIOptionalRequestParams(reasoning={"effort": "low"}),
model="gpt-6-sol",
drop_params=True,
)
assert bare["reasoning"] == {"effort": "low"}
_COMPLETED_RESPONSE: Final = {
"id": "resp_dated_chat_deployment",
"object": "response",

View file

@ -306,17 +306,30 @@ def _local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "model_cost", get_model_cost_map(url=litellm.model_cost_map_url))
def test_foundry_gpt_6_astra_keeps_sampling_params_when_reasoning_effort_is_none(_local_model_cost_map):
def test_foundry_gpt_6_sol_keeps_sampling_params_when_reasoning_effort_is_none(_local_model_cost_map):
"""gpt-6-sol's Foundry row still says none, so the sampling params that ride on it stay. gpt-6-astra
used to be the model here until its row turned none off (a live Foundry deployment answered none
with a 400 naming low through xhigh on 2026-10-01); the test below pins that flip."""
optional_params = AzureAIStudioConfig().map_openai_params(
non_default_params={"reasoning_effort": "none", "temperature": 0.2, "top_p": 0.9},
optional_params={},
model="gpt-6-astra",
model="gpt-6-sol",
drop_params=False,
)
assert optional_params == {"reasoning_effort": "none", "temperature": 0.2, "top_p": 0.9}
def test_foundry_gpt_6_astra_refuses_reasoning_effort_none_before_the_call(_local_model_cost_map):
with pytest.raises(litellm.utils.UnsupportedParamsError):
AzureAIStudioConfig().map_openai_params(
non_default_params={"reasoning_effort": "none"},
optional_params={},
model="gpt-6-astra",
drop_params=False,
)
def test_azure_ai_grok_stop_parameter_handling():

View file

@ -1,12 +1,15 @@
import json
from typing import Final
import httpx
import pytest
import respx
import litellm
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
from litellm.llms.azure_ai.responses.transformation import AzureAIResponsesAPIConfig
from litellm.responses.main import _will_bridge_to_chat_completions
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
from litellm.utils import ProviderConfigManager
@ -309,3 +312,74 @@ async def test_aresponses_serverless_host_stays_on_chat_bridge():
assert chat_route.called
assert not responses_route.called
assert chat_route.calls.last.request.headers["Authorization"] == "Bearer fake-key"
def _responses_params(**fields: object) -> ResponsesAPIOptionalRequestParams:
return ResponsesAPIOptionalRequestParams(**fields) # pyright: ignore[reportArgumentType] # TypedDict kwargs from a test helper
def test_foundry_responses_effort_gate_reads_the_azure_ai_row(
local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
):
"""A Foundry deployment and its Azure OpenAI namesake are different products, so the Foundry
Responses route answers from the azure_ai/ row the Foundry chat route already reads, never from
the azure/ twin. The rows are made to disagree on low, a level neither real row flags, so a
leak from the wrong row cannot hide behind rows that happen to agree."""
monkeypatch.setitem(
litellm.model_cost,
"azure_ai/gpt-6-sol",
{**litellm.model_cost["azure_ai/gpt-6-sol"], "supports_low_reasoning_effort": False},
)
monkeypatch.setitem(
litellm.model_cost,
"azure/gpt-6-sol",
{**litellm.model_cost["azure/gpt-6-sol"], "supports_low_reasoning_effort": True},
)
dropped: Final = AzureAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=_responses_params(reasoning={"effort": "low"}),
model="gpt-6-sol",
drop_params=True,
)
assert "reasoning" not in dropped
with pytest.raises(litellm.UnsupportedParamsError):
AzureAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=_responses_params(reasoning={"effort": "low"}),
model="gpt-6-sol",
drop_params=False,
)
kept_by_azure_openai: Final = AzureOpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=_responses_params(reasoning={"effort": "low"}),
model="gpt-6-sol",
drop_params=True,
)
assert kept_by_azure_openai["reasoning"] == {"effort": "low"}
def test_foundry_responses_none_unlocks_temperature_off_the_azure_ai_row(
local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
):
"""The sampling-param unlock that rides on none reads the same azure_ai/ row as the effort gate:
with the azure/ twin turned off for none, the Foundry row still lets none through and, only
then, a non-default temperature."""
monkeypatch.setitem(
litellm.model_cost,
"azure_ai/gpt-6-sol",
{**litellm.model_cost["azure_ai/gpt-6-sol"], "supports_none_reasoning_effort": True},
)
monkeypatch.setitem(
litellm.model_cost,
"azure/gpt-6-sol",
{**litellm.model_cost["azure/gpt-6-sol"], "supports_none_reasoning_effort": False},
)
params: Final = AzureAIResponsesAPIConfig().map_openai_params(
response_api_optional_params=_responses_params(temperature=0.2, reasoning={"effort": "none"}),
model="gpt-6-sol",
drop_params=False,
)
assert params["temperature"] == 0.2
assert params["reasoning"] == {"effort": "none"}

View file

@ -1,8 +1,14 @@
import os
from typing import Final
import pytest
from unittest.mock import patch
import httpx
import pytest
import respx
import litellm
from litellm.llms.baseten.chat import BasetenConfig
from litellm.utils import get_valid_models
class TestBasetenRouting:
@ -50,6 +56,107 @@ class TestBasetenModelAPI:
assert api_key == "test-key"
class TestBasetenModelDiscovery:
"""Test Baseten model listing for `baseten/*` wildcard expansion and /v1/models"""
def test_models_by_provider_comes_from_registry(self):
"""Static listing uses the model cost map, not the retired 2023 deployment IDs."""
baseten_models: Final = litellm.models_by_provider["baseten"]
servable_registry_models: Final = {
key
for key, value in litellm.model_cost.items()
if value.get("litellm_provider") == "baseten" and not litellm._is_past_deprecation_date(value)
}
assert servable_registry_models
assert servable_registry_models <= set(baseten_models)
for retired_id in ("qvv0xeq", "q841o8w", "31dxrj3"):
assert retired_id not in baseten_models
def test_models_by_provider_skips_deprecated_models(self):
"""Retired Model APIs stay in the cost map for spend tracking but are not listed."""
assert litellm.model_cost["baseten/zai-org/GLM-5"]["deprecation_date"]
assert "baseten/zai-org/GLM-5" not in litellm.models_by_provider["baseten"]
def test_cost_map_reload_drops_a_newly_retired_model(self):
"""A refreshed cost map that marks a listed model retired removes it without a restart."""
model: Final = "baseten/acme/reload-probe"
try:
litellm.add_known_models({model: {"litellm_provider": "baseten"}})
assert model in litellm.models_by_provider["baseten"]
litellm.add_known_models({model: {"litellm_provider": "baseten", "deprecation_date": "2000-01-01"}})
assert model not in litellm.models_by_provider["baseten"]
finally:
litellm.baseten_models.discard(model)
litellm.add_known_models({})
@pytest.mark.parametrize(
"value, expected",
[
({}, False),
({"deprecation_date": "2000-01-01"}, True),
({"deprecation_date": "2999-01-01"}, False),
({"deprecation_date": "not-a-date"}, False),
],
)
def test_is_past_deprecation_date(self, value, expected):
assert litellm._is_past_deprecation_date(value) is expected
def test_get_models_calls_model_api(self, respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.get("https://inference.baseten.co/v1/models").mock(
return_value=httpx.Response(
200,
json={
"object": "list",
"data": [
{"id": "zai-org/GLM-5", "object": "model"},
{"id": "moonshotai/Kimi-K2.5", "object": "model"},
],
},
)
)
models: Final = BasetenConfig().get_models(api_key="test-key")
assert models == ["baseten/zai-org/GLM-5", "baseten/moonshotai/Kimi-K2.5"]
assert route.calls.last.request.headers["Authorization"] == "Bearer test-key"
def test_get_models_respects_custom_api_base(self, respx_mock: respx.MockRouter) -> None:
respx_mock.get("https://custom.baseten.example/v1/models").mock(
return_value=httpx.Response(200, json={"data": [{"id": "zai-org/GLM-5"}]})
)
models: Final = BasetenConfig().get_models(api_key="test-key", api_base="https://custom.baseten.example/v1/")
assert models == ["baseten/zai-org/GLM-5"]
@patch.dict(os.environ, {}, clear=True)
def test_get_models_requires_api_key(self):
with pytest.raises(ValueError, match="BASETEN_API_KEY"):
BasetenConfig().get_models()
def test_get_models_raises_on_error_status(self, respx_mock: respx.MockRouter) -> None:
respx_mock.get("https://inference.baseten.co/v1/models").mock(
return_value=httpx.Response(403, text="forbidden")
)
with pytest.raises(ValueError, match="403"):
BasetenConfig().get_models(api_key="test-key")
@patch.dict(os.environ, {"BASETEN_API_KEY": "test-key"})
def test_get_valid_models_check_provider_endpoint(self, respx_mock: respx.MockRouter) -> None:
"""check_provider_endpoint=True returns the live catalog instead of the static list."""
respx_mock.get("https://inference.baseten.co/v1/models").mock(
return_value=httpx.Response(200, json={"data": [{"id": "brand-new/model"}]})
)
models: Final = get_valid_models(check_provider_endpoint=True, custom_llm_provider="baseten")
assert models == ["baseten/brand-new/model"]
class TestBasetenReasoningEffort:
def test_reasoning_effort_passthrough_with_drop_params(self):
config: Final = BasetenConfig()

View file

@ -0,0 +1,143 @@
"""
Tests for the DeepSeek Search provider (POST https://api.deepseek.com/v1/tools/search).
"""
from typing import Final
import re
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import litellm
from litellm.llms.deepseek.search.transformation import DeepSeekSearchConfig
@pytest.mark.asyncio
async def test_asearch_sends_deepseek_body_and_maps_results(monkeypatch):
monkeypatch.setenv("DEEPSEEK_API_KEY", "test-deepseek-key")
monkeypatch.delenv("DEEPSEEK_API_BASE", raising=False)
mock_response: Final = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"results": [
{
"rerank_score": 2.4,
"title": "DeepSeek V4 model released",
"url": "https://example.com/deepseek-v4",
"snippet": "short snippet",
"summary": "long summary for the model",
"content": "full page content",
"site_name": None,
"date": "2026-06-01T00:00:00+00:00",
"authority": 3,
},
{"title": None, "url": "https://example.com/degraded", "snippet": None, "date": None},
]
}
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_post:
response: Final = await litellm.asearch(
query=["DeepSeek V4", "release"],
search_provider="deepseek",
max_results=5,
search_domain_filter=["github.com", "huggingface.co"],
need_content=True,
)
assert mock_post.call_count == 1
call: Final = mock_post.call_args
assert call.kwargs["url"] == "https://api.deepseek.com/v1/tools/search"
assert call.kwargs["headers"]["Authorization"] == "Bearer test-deepseek-key"
assert call.kwargs["headers"]["Content-Type"] == "application/json"
assert call.kwargs["json"] == {
"query": "DeepSeek V4 release site:github.com OR site:huggingface.co",
"count": 5,
"need_content": True,
}
assert response.object == "search"
assert len(response.results) == 2
first: Final = response.results[0]
assert (first.title, first.url, first.snippet) == (
"DeepSeek V4 model released",
"https://example.com/deepseek-v4",
"short snippet",
)
assert first.date == "2026-06-01T00:00:00+00:00"
assert first.last_updated is None
assert first.model_extra["summary"] == "long summary for the model"
assert first.model_extra["content"] == "full page content"
assert first.model_extra["rerank_score"] == 2.4
degraded: Final = response.results[1]
assert (degraded.title, degraded.snippet, degraded.date) == ("", "", None)
@pytest.mark.parametrize(
("max_results", "expected_count"),
[(0, 1), (3, 3), (50, 50), (500, 50)],
)
def test_max_results_clamps_to_deepseek_count_range(max_results, expected_count):
body: Final = DeepSeekSearchConfig().transform_search_request(
query="litellm", optional_params={"max_results": max_results}
)
assert body == {"query": "litellm", "count": expected_count}
def test_query_over_deepseek_limit_fails_before_the_call():
with pytest.raises(
litellm.BadRequestError, match=re.escape("at most 128 characters, got 144 (3 search_domain_filter")
):
DeepSeekSearchConfig().transform_search_request(
query="litellm proxy search endpoint configuration guide for enterprise deployments with",
optional_params={"search_domain_filter": ["docs.litellm.ai", "github.com", "huggingface.co"]},
)
def test_unified_params_without_deepseek_equivalent_are_not_sent():
body: Final = DeepSeekSearchConfig().transform_search_request(
query="litellm",
optional_params={"country": "US", "max_tokens_per_page": 1024, "ori_question": "what is litellm"},
)
assert body == {"query": "litellm", "ori_question": "what is litellm"}
@pytest.mark.parametrize(
"api_base",
[
None,
"https://api.deepseek.com",
"https://api.deepseek.com/",
"https://api.deepseek.com/beta",
"https://api.deepseek.com/v1",
"https://api.deepseek.com/v1/tools/search",
],
)
def test_complete_url_resolves_search_route_from_chat_style_api_base(api_base, monkeypatch):
monkeypatch.delenv("DEEPSEEK_API_BASE", raising=False)
url: Final = DeepSeekSearchConfig().get_complete_url(api_base=api_base, optional_params={})
assert url == "https://api.deepseek.com/v1/tools/search"
def test_missing_api_key_raises(monkeypatch):
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
with pytest.raises(ValueError, match="DEEPSEEK_API_KEY"):
DeepSeekSearchConfig().validate_environment(headers={})
def test_search_cost_comes_from_cost_map(monkeypatch):
monkeypatch.setenv("DEEPSEEK_API_KEY", "test-deepseek-key")
priced: Final[float] = litellm.model_cost["deepseek/search"]["input_cost_per_query"]
assert priced > 0
mock_response: Final = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"results": [{"title": "t", "url": "https://e.com", "snippet": "s"}]}
with patch(
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post",
return_value=mock_response,
):
response: Final = litellm.search(query="litellm", search_provider="deepseek")
assert response._hidden_params["response_cost"] == pytest.approx(priced)

View file

@ -2303,6 +2303,115 @@ class TestResponsesSurfaceSharesTheEffortRule:
)
assert ("top_p" in mapped) is top_p_survives
@pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"])
def test_effort_levels_the_map_row_turns_off_are_dropped_or_refused(self, local_model_cost_map, level):
"""Same row as the chat gate, read opt-out for every level but max. gpt-6.1-sol's row turns
none and minimal off: OpenAI lists low, medium, high, xhigh and max for it
(https://developers.openai.com/api/docs/models/gpt-6.1-sol, via #43910), and a local proxy
got OpenAI's 400 for none and minimal through /v1/responses on 2026-09-30."""
forwarded = (
level == "max" or litellm.model_cost["gpt-6.1-sol"].get(f"supports_{level}_reasoning_effort") is not False
)
dropped = OpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params={"reasoning": {"effort": level}},
model="gpt-6.1-sol",
drop_params=True,
)
assert ("reasoning" in dropped) is forwarded
if forwarded:
kept = OpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params={"reasoning": {"effort": level}},
model="gpt-6.1-sol",
drop_params=False,
)
assert kept["reasoning"] == {"effort": level}
else:
with pytest.raises(litellm.UnsupportedParamsError):
OpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params={"reasoning": {"effort": level}},
model="gpt-6.1-sol",
drop_params=False,
)
@pytest.mark.parametrize(
("flags", "level", "forwarded"),
[
({}, "none", True),
({"supports_none_reasoning_effort": False}, "none", False),
({"supports_none_reasoning_effort": True}, "none", True),
({"supports_minimal_reasoning_effort": False}, "minimal", False),
({"supports_max_reasoning_effort": False}, "max", True),
({}, "xhigh", True),
({"supports_xhigh_reasoning_effort": False}, "xhigh", False),
({"supports_none_reasoning_effort": False}, "medium", True),
],
)
def test_the_row_flags_alone_decide_whether_a_level_is_forwarded(
self, local_model_cost_map, monkeypatch, flags, level, forwarded
):
"""Same model name, rewritten row: this surface reads every level opt-out, xhigh included,
so the gate flips with the flags alone. max is the one level no flag can turn off here,
since the same model honors it on this route and refuses it on chat. The chat test module
pins the real row's facts."""
real_row = litellm.model_cost["gpt-6.1-sol"]
without_flags = {
field: value
for field, value in real_row.items()
if not (field.startswith("supports_") and field.endswith("_reasoning_effort"))
}
monkeypatch.setitem(litellm.model_cost, "gpt-6.1-sol", {**without_flags, **flags})
dropped = OpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params={"reasoning": {"effort": level}},
model="gpt-6.1-sol",
drop_params=True,
)
assert ("reasoning" in dropped) is forwarded
if not forwarded:
with pytest.raises(litellm.UnsupportedParamsError):
OpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params={"reasoning": {"effort": level}},
model="gpt-6.1-sol",
drop_params=False,
)
def test_dropping_a_refused_effort_keeps_the_rest_of_the_reasoning_object(self, local_model_cost_map):
refused = next(
level
for level in ("none", "minimal", "low")
if litellm.model_cost["gpt-6.1-sol"].get(f"supports_{level}_reasoning_effort") is False
)
mapped = OpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params={"reasoning": {"effort": refused, "summary": "auto"}},
model="gpt-6.1-sol",
drop_params=True,
)
assert mapped["reasoning"] == {"summary": "auto"}
@pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"])
def test_gpt5_3_codex_forwards_exactly_the_effort_levels_its_map_row_allows(self, local_model_cost_map, level):
"""The gate turns a stale false into a local refusal, so the Responses-only codex row has to
say what the route does. On 2026-10-01 /v1/responses answered gpt-5.3-codex none and xhigh
with 200 (effort echoed back) and minimal and max with 400 unsupported_value naming none,
low, medium, high and xhigh, so the row takes none and xhigh and turns minimal off."""
forwarded = (
level == "max" or litellm.model_cost["gpt-5.3-codex"].get(f"supports_{level}_reasoning_effort") is not False
)
mapped = OpenAIResponsesAPIConfig().map_openai_params(
response_api_optional_params={"reasoning": {"effort": level}},
model="gpt-5.3-codex",
drop_params=True,
)
assert ("reasoning" in mapped) is forwarded
def test_gpt5_3_codex_row_takes_none_and_xhigh(self, local_model_cost_map):
"""Pinned on purpose so the row-derived test above cannot go vacuous: the live answers of
2026-10-01 listed above. When OpenAI changes the set, flip the row and this assertion
together."""
row = litellm.model_cost["gpt-5.3-codex"]
assert row["supports_none_reasoning_effort"] is True
assert row["supports_xhigh_reasoning_effort"] is True
assert row["supports_minimal_reasoning_effort"] is False
def test_top_p_raises_without_drop_params(self, local_model_cost_map):
with pytest.raises(litellm.UnsupportedParamsError):
OpenAIResponsesAPIConfig().map_openai_params(

View file

@ -1,4 +1,6 @@
import re
from collections.abc import Mapping
from typing import Final
import pytest
@ -431,6 +433,105 @@ def test_gpt5_drops_reasoning_effort_minimal_when_requested(config: OpenAIConfig
assert "reasoning_effort" not in params
def _chat_row_forwards(row: Mapping[str, object], level: str) -> bool:
"""Chat polarity: max is never gated, xhigh needs an explicit true, every other level only has
to not be false."""
if level == "max":
return True
flag: Final = row.get(f"supports_{level}_reasoning_effort")
return flag is True if level == "xhigh" else flag is not False
@pytest.mark.parametrize("level", ["none", "minimal", "low", "medium", "high", "xhigh", "max"])
def test_gpt6_1_sol_forwards_exactly_the_effort_levels_its_map_row_allows(config: OpenAIConfig, level: str):
"""The map row is the whole contract, so a new GPT-6 model is gated by its row with no code
change. gpt-6.1-sol's row turns none and minimal off: OpenAI lists low, medium, high, xhigh
and max for it (https://developers.openai.com/api/docs/models/gpt-6.1-sol, via #43910), and a
local proxy got OpenAI's 400 for none and minimal on both endpoints on 2026-09-30."""
forwarded: Final = _chat_row_forwards(litellm.model_cost["gpt-6.1-sol"], level)
dropped: Final = config.map_openai_params(
non_default_params={"reasoning_effort": level},
optional_params={},
model="gpt-6.1-sol",
drop_params=True,
)
assert ("reasoning_effort" in dropped) is forwarded
if forwarded:
kept: Final = config.map_openai_params(
non_default_params={"reasoning_effort": level},
optional_params={},
model="gpt-6.1-sol",
drop_params=False,
)
assert kept["reasoning_effort"] == level
else:
with pytest.raises(litellm.utils.UnsupportedParamsError):
config.map_openai_params(
non_default_params={"reasoning_effort": level},
optional_params={},
model="gpt-6.1-sol",
drop_params=False,
)
def test_gpt6_1_sol_row_turns_none_and_minimal_off():
"""Pinned on purpose so the row-derived test above cannot go vacuous: OpenAI lists low, medium,
high, xhigh and max for gpt-6.1-sol and neither none nor minimal
(https://developers.openai.com/api/docs/models/gpt-6.1-sol, read 2026-09-30 via #43910), and a
local proxy got OpenAI's 400 for both on 2026-09-30. When OpenAI adds a level, flip the row and
this assertion together."""
row: Final = litellm.model_cost["gpt-6.1-sol"]
assert row["supports_none_reasoning_effort"] is False
assert row["supports_minimal_reasoning_effort"] is False
def _row_without_effort_flags(row: Mapping[str, object]) -> dict[str, object]:
return {
field: value
for field, value in row.items()
if not (field.startswith("supports_") and field.endswith("_reasoning_effort"))
}
@pytest.mark.parametrize(
("flags", "level", "forwarded"),
[
({}, "none", True),
({"supports_none_reasoning_effort": False}, "none", False),
({"supports_none_reasoning_effort": True}, "none", True),
({"supports_minimal_reasoning_effort": False}, "minimal", False),
({"supports_max_reasoning_effort": False}, "max", True),
({}, "max", True),
({}, "xhigh", False),
({"supports_xhigh_reasoning_effort": True}, "xhigh", True),
({"supports_none_reasoning_effort": False}, "medium", True),
],
)
def test_the_row_flags_alone_decide_whether_a_chat_level_is_forwarded(
config: OpenAIConfig, monkeypatch: pytest.MonkeyPatch, flags: dict[str, bool], level: str, forwarded: bool
):
"""Same model name, rewritten row: the gate flips with the flags and nothing else, so breaking the
gate fails here while the pinned test above catches the real row losing a flag."""
monkeypatch.setitem(
litellm.model_cost, "gpt-6.1-sol", {**_row_without_effort_flags(litellm.model_cost["gpt-6.1-sol"]), **flags}
)
dropped: Final = config.map_openai_params(
non_default_params={"reasoning_effort": level},
optional_params={},
model="gpt-6.1-sol",
drop_params=True,
)
assert ("reasoning_effort" in dropped) is forwarded
if not forwarded:
with pytest.raises(litellm.utils.UnsupportedParamsError):
config.map_openai_params(
non_default_params={"reasoning_effort": level},
optional_params={},
model="gpt-6.1-sol",
drop_params=False,
)
def test_gpt5_minimal_dict_triggers_validation(config: OpenAIConfig):
"""Dict with effort='minimal' triggers minimal model-support validation."""
with pytest.raises(litellm.utils.UnsupportedParamsError):

View file

@ -2472,6 +2472,7 @@ def test_proxy_admin_viewer_can_access_settings_read_endpoints(route):
ADMIN_VIEWER_REPORTED_GET_ROUTES = [
"/health/latest",
"/credentials",
"/model/model-pods-1/kubernetes_pods",
"/v1/mcp/network/client-ip",
"/claude-code/plugins",
"/policy/templates",
@ -3615,6 +3616,59 @@ def test_internal_user_still_blocked_from_another_users_info():
assert "key not allowed to access this user's info" in str(exc_info.value.detail)
def test_internal_user_cannot_access_kubernetes_pod_discovery_route() -> None:
route: Final = "/model/model-pods-1/kubernetes_pods"
user_obj: Final = LiteLLM_UserTable(
user_id="internal_user",
user_email="user@example.com",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
valid_token: Final = UserAPIKeyAuth(
user_id="internal_user",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
request: Final = MagicMock(spec=Request)
request.method = "GET"
request.query_params = {}
assert RouteChecks.is_management_route(route=route)
with pytest.raises(Exception, match="Only proxy admin can be used"):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route=route,
request=request,
valid_token=valid_token,
request_data={},
)
def test_proxy_admin_viewer_can_access_kubernetes_pod_discovery_route() -> None:
route: Final = "/model/model-pods-1/kubernetes_pods"
user_obj: Final = LiteLLM_UserTable(
user_id="admin_viewer",
user_email="viewer@example.com",
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
)
valid_token: Final = UserAPIKeyAuth(
user_id="admin_viewer",
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
)
request: Final = MagicMock(spec=Request)
request.method = "GET"
request.query_params = {}
assert RouteChecks.is_management_route(route=route)
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
route=route,
request=request,
valid_token=valid_token,
request_data={},
)
@pytest.mark.parametrize(
"route",
[

View file

@ -0,0 +1,467 @@
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
from litellm.models.verification_token import LiteLLM_VerificationToken
from litellm.proxy._types import (
LiteLLM_TeamTableCachedObj,
LitellmUserRoles,
Member,
ProxyException,
UpdateKeyRequest,
UserAPIKeyAuth,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.key_budget_change import (
KeyBudgetAdminOnly,
KeyBudgetChange,
KeyBudgetLoosened,
KeyBudgetTightened,
KeyBudgetUnchanged,
classify_key_budget_change,
resolve_self_serve_budget_policy,
)
from litellm.proxy.management_endpoints.key_management_endpoints import _validate_update_key_data
from litellm.types.proxy.management_endpoints.ui_sso import LiteLLM_UpperboundKeyGenerateParams
_OWNER: Final = "owner-1"
_TEAM_ADMIN: Final = "team-admin"
_TEAM: Final = "team-1"
_WEEKLY: Final[Mapping[str, object]] = MappingProxyType(
{"budget_duration": "7d", "max_budget": 50.0, "reset_at": "2026-10-08T00:00:00+00:00"}
)
def _key(
*,
user_id: str = _OWNER,
created_by: str = _OWNER,
team_id: str | None = None,
max_budget: float | None = None,
budget_limits: Sequence[Mapping[str, object]] | None = None,
budget_id: str | None = None,
) -> LiteLLM_VerificationToken:
return LiteLLM_VerificationToken(
token="hashed-owner-key",
user_id=user_id,
created_by=created_by,
team_id=team_id,
max_budget=max_budget,
budget_limits=None if budget_limits is None else [dict(window) for window in budget_limits],
budget_id=budget_id,
metadata={},
)
def _window(duration: str, cap: float) -> Mapping[str, object]:
return MappingProxyType({"budget_duration": duration, "max_budget": cap})
@pytest.mark.parametrize(
("update", "stored", "expected"),
[
(UpdateKeyRequest(key="sk-1", key_alias="renamed"), _key(max_budget=10.0), KeyBudgetUnchanged()),
(UpdateKeyRequest(key="sk-1", max_budget=10.0), _key(max_budget=10.0), KeyBudgetUnchanged()),
(UpdateKeyRequest(key="sk-1", max_budget=5.0), _key(max_budget=10.0), KeyBudgetTightened()),
(UpdateKeyRequest(key="sk-1", max_budget=5.0), _key(), KeyBudgetTightened()),
(UpdateKeyRequest(key="sk-1", max_budget=20.0), _key(max_budget=10.0), KeyBudgetLoosened()),
(UpdateKeyRequest(key="sk-1", max_budget=None), _key(max_budget=10.0), KeyBudgetLoosened()),
(UpdateKeyRequest(key="sk-1", budget_limits=[_window("24h", 5.0)]), _key(), KeyBudgetTightened()),
(
UpdateKeyRequest(key="sk-1", budget_limits=[_WEEKLY, _window("24h", 5.0)]),
_key(budget_limits=[_WEEKLY]),
KeyBudgetTightened(),
),
(
UpdateKeyRequest(key="sk-1", budget_limits=[_window("7d", 50.0)]),
_key(budget_limits=[_WEEKLY]),
KeyBudgetTightened(),
),
(
UpdateKeyRequest(key="sk-1", budget_limits=[_window("7d", 40.0)]),
_key(budget_limits=[_WEEKLY]),
KeyBudgetTightened(),
),
(
UpdateKeyRequest(key="sk-1", budget_limits=[_window("7d", 60.0)]),
_key(budget_limits=[_WEEKLY]),
KeyBudgetLoosened(),
),
(
UpdateKeyRequest(key="sk-1", budget_limits=[_window("24h", 5.0)]),
_key(budget_limits=[_WEEKLY]),
KeyBudgetLoosened(),
),
(UpdateKeyRequest(key="sk-1", budget_limits=[]), _key(budget_limits=[_WEEKLY]), KeyBudgetLoosened()),
(UpdateKeyRequest(key="sk-1", budget_limits=None), _key(budget_limits=[_WEEKLY]), KeyBudgetLoosened()),
(
UpdateKeyRequest(key="sk-1", budget_limits=[_window("1d", 5.0)]),
_key(budget_limits=[_window("24h", 5.0)]),
KeyBudgetLoosened(),
),
(
UpdateKeyRequest(key="sk-1", budget_limits=[_window("7d", 40.0)]),
_key(budget_limits=[_WEEKLY, _window("7d", 30.0)]),
KeyBudgetLoosened(),
),
(
UpdateKeyRequest(key="sk-1", budget_limits=[_window("7d", 40.0), _window("24h", 5.0)]),
_key(budget_limits=[_window("7d", 30.0), _window("24h", 5.0), _window("7d", 50.0)]),
KeyBudgetLoosened(),
),
(
UpdateKeyRequest(key="sk-1", budget_limits=[_window("24h", 5.0)]),
_key(budget_limits=[{"budget_duration": "24h"}]),
KeyBudgetLoosened(),
),
(
UpdateKeyRequest(key="sk-1", max_budget=5.0, budget_limits=[]),
_key(max_budget=10.0, budget_limits=[_WEEKLY]),
KeyBudgetLoosened(),
),
(UpdateKeyRequest(key="sk-1", spend=0.0), _key(), KeyBudgetAdminOnly()),
(UpdateKeyRequest(key="sk-1", soft_budget=1.0), _key(), KeyBudgetAdminOnly()),
(UpdateKeyRequest(key="sk-1", soft_budget=None, max_budget=5.0), _key(max_budget=10.0), KeyBudgetAdminOnly()),
],
)
def test_classify_key_budget_change(
update: UpdateKeyRequest, stored: LiteLLM_VerificationToken, expected: KeyBudgetChange
) -> None:
assert classify_key_budget_change(update, stored) == expected
@pytest.mark.parametrize(
("settings", "expected"),
[
({}, "disabled"),
({"self_serve_budget_policy": "lower_only"}, "lower_only"),
({"self_serve_budget_policy": "ceiling"}, "ceiling"),
({"self_serve_budget_policy": "everything"}, "disabled"),
({"self_serve_budget_policy": True}, "disabled"),
],
)
def test_resolve_self_serve_budget_policy(settings: Mapping[str, object], expected: str) -> None:
assert resolve_self_serve_budget_policy(settings) == expected
def _team(member_permissions: Sequence[str]) -> LiteLLM_TeamTableCachedObj:
return LiteLLM_TeamTableCachedObj(
team_id=_TEAM,
members_with_roles=[Member(user_id=_TEAM_ADMIN, role="admin"), Member(user_id=_OWNER, role="user")],
team_member_permissions=list(member_permissions),
)
def _caller(
*,
user_id: str = _OWNER,
max_budget: float | None = None,
team_id: str | None = None,
user_max_budget: float | None = None,
) -> UserAPIKeyAuth:
return UserAPIKeyAuth(
user_id=user_id,
user_role=LitellmUserRoles.INTERNAL_USER,
max_budget=max_budget,
team_id=team_id,
user_max_budget=user_max_budget,
)
async def _update(
monkeypatch: pytest.MonkeyPatch,
*,
policy: str | None,
data: UpdateKeyRequest,
existing: LiteLLM_VerificationToken,
team: LiteLLM_TeamTableCachedObj | None = None,
caller: UserAPIKeyAuth | None = None,
) -> None:
monkeypatch.setattr(
"litellm.proxy.proxy_server.general_settings",
{} if policy is None else {"self_serve_budget_policy": policy},
)
prisma: Final = MagicMock()
prisma.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=existing)
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team)
prisma.writer_db.litellm_teamtable.find_unique = AsyncMock(return_value=team)
await _validate_update_key_data(
data=data,
existing_key_row=existing,
user_api_key_dict=caller or _caller(),
llm_router=None,
premium_user=False,
prisma_client=prisma,
user_api_key_cache=UserApiKeyCache(),
)
async def _denial(
monkeypatch: pytest.MonkeyPatch,
*,
policy: str | None,
data: UpdateKeyRequest,
existing: LiteLLM_VerificationToken,
team: LiteLLM_TeamTableCachedObj | None = None,
caller: UserAPIKeyAuth | None = None,
) -> HTTPException | None:
try:
await _update(monkeypatch, policy=policy, data=data, existing=existing, team=team, caller=caller)
except HTTPException as exc:
return exc
return None
_ADD_DAILY_WINDOW: Final = UpdateKeyRequest(key="sk-1", budget_limits=[_WEEKLY, _window("24h", 5.0)])
_RAISE_WEEKLY_WINDOW: Final = UpdateKeyRequest(key="sk-1", budget_limits=[_window("7d", 80.0)])
def _team_key_with_weekly(*, user_id: str = _OWNER, created_by: str = _OWNER) -> LiteLLM_VerificationToken:
return _key(user_id=user_id, created_by=created_by, team_id=_TEAM, budget_limits=[_WEEKLY])
@pytest.mark.asyncio
@pytest.mark.parametrize(
("policy", "data", "member_permissions"),
[
("lower_only", _ADD_DAILY_WINDOW, ["/key/update"]),
("ceiling", _ADD_DAILY_WINDOW, ["/key/update"]),
("ceiling", _RAISE_WEEKLY_WINDOW, ["/key/update", "/key/generate"]),
],
)
async def test_owner_changes_team_key_budget_when_policy_allows(
monkeypatch: pytest.MonkeyPatch, policy: str, data: UpdateKeyRequest, member_permissions: Sequence[str]
) -> None:
assert (
await _denial(
monkeypatch,
policy=policy,
data=data,
existing=_team_key_with_weekly(),
team=_team(member_permissions),
)
is None
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("policy", "data", "member_permissions"),
[
(None, _ADD_DAILY_WINDOW, ["/key/update", "/key/generate"]),
("disabled", _ADD_DAILY_WINDOW, ["/key/update", "/key/generate"]),
("lower_only", _RAISE_WEEKLY_WINDOW, ["/key/update", "/key/generate"]),
("ceiling", _RAISE_WEEKLY_WINDOW, ["/key/update"]),
("ceiling", UpdateKeyRequest(key="sk-1", soft_budget=1.0), ["/key/update", "/key/generate"]),
("ceiling", UpdateKeyRequest(key="sk-1", spend=0.0), ["/key/update", "/key/generate"]),
],
)
async def test_owner_team_key_budget_change_needs_admin(
monkeypatch: pytest.MonkeyPatch, policy: str | None, data: UpdateKeyRequest, member_permissions: Sequence[str]
) -> None:
with pytest.raises(HTTPException) as exc:
await _update(
monkeypatch,
policy=policy,
data=data,
existing=_team_key_with_weekly(),
team=_team(member_permissions),
)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_key_created_by_someone_else_needs_admin_even_to_tighten(monkeypatch: pytest.MonkeyPatch) -> None:
with pytest.raises(HTTPException) as exc:
await _update(
monkeypatch,
policy="ceiling",
data=_ADD_DAILY_WINDOW,
existing=_team_key_with_weekly(created_by=_TEAM_ADMIN),
team=_team(["/key/update", "/key/generate"]),
)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_team_member_without_update_permission_is_still_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
with pytest.raises(ProxyException) as exc:
await _update(
monkeypatch,
policy="ceiling",
data=_ADD_DAILY_WINDOW,
existing=_team_key_with_weekly(),
team=_team(["/key/generate"]),
)
assert str(exc.value.code) == "401"
@pytest.mark.asyncio
async def test_ceiling_lets_owner_raise_personal_key_budget(monkeypatch: pytest.MonkeyPatch) -> None:
assert (
await _denial(
monkeypatch,
policy="ceiling",
data=UpdateKeyRequest(key="sk-1", max_budget=30.0),
existing=_key(max_budget=10.0),
)
is None
)
@pytest.mark.asyncio
async def test_ceiling_caps_owner_raise_at_callers_own_budget(monkeypatch: pytest.MonkeyPatch) -> None:
with pytest.raises(HTTPException) as exc:
await _update(
monkeypatch,
policy="ceiling",
data=UpdateKeyRequest(key="sk-1", max_budget=30.0),
existing=_key(max_budget=10.0),
caller=_caller(max_budget=20.0),
)
assert exc.value.status_code == 400
assert "cannot exceed the caller's own max_budget (20.0)" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_ceiling_does_not_cap_a_team_admin_raising_their_own_team_key(monkeypatch: pytest.MonkeyPatch) -> None:
assert (
await _denial(
monkeypatch,
policy="ceiling",
data=UpdateKeyRequest(key="sk-1", max_budget=30.0),
existing=_key(user_id=_TEAM_ADMIN, created_by=_TEAM_ADMIN, team_id=_TEAM, max_budget=10.0),
team=_team(["/key/update"]),
caller=_caller(user_id=_TEAM_ADMIN, max_budget=20.0),
)
is None
)
@pytest.mark.asyncio
async def test_ceiling_caps_ui_session_owner_raise_at_user_budget(monkeypatch: pytest.MonkeyPatch) -> None:
with pytest.raises(HTTPException) as exc:
await _update(
monkeypatch,
policy="ceiling",
data=UpdateKeyRequest(key="sk-1", max_budget=30.0),
existing=_key(max_budget=10.0),
caller=_caller(team_id=UI_SESSION_TOKEN_TEAM_ID, user_max_budget=20.0),
)
assert exc.value.status_code == 400
assert "cannot exceed the caller's own max_budget (20.0)" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_ceiling_respects_personal_key_generation_role_restriction(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"litellm.key_generation_settings",
{"personal_key_generation": {"allowed_user_roles": [LitellmUserRoles.PROXY_ADMIN.value]}},
)
with pytest.raises(HTTPException) as exc:
await _update(
monkeypatch,
policy="ceiling",
data=UpdateKeyRequest(key="sk-1", max_budget=None),
existing=_key(max_budget=10.0),
)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_lower_only_lets_owner_lower_personal_key_budget_without_generate_rights(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"litellm.key_generation_settings",
{"personal_key_generation": {"allowed_user_roles": [LitellmUserRoles.PROXY_ADMIN.value]}},
)
assert (
await _denial(
monkeypatch,
policy="lower_only",
data=UpdateKeyRequest(key="sk-1", max_budget=5.0),
existing=_key(max_budget=10.0),
)
is None
)
@pytest.mark.asyncio
async def test_ceiling_respects_team_key_generation_role_restriction(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"litellm.key_generation_settings",
{"team_key_generation": {"allowed_team_member_roles": ["admin"]}},
)
with pytest.raises(HTTPException) as exc:
await _update(
monkeypatch,
policy="ceiling",
data=_RAISE_WEEKLY_WINDOW,
existing=_team_key_with_weekly(),
team=_team(["/key/update", "/key/generate"]),
)
assert exc.value.status_code == 403
@pytest.mark.asyncio
@pytest.mark.parametrize("policy", ["lower_only", "ceiling"])
async def test_owner_cannot_override_a_cap_inherited_from_a_linked_budget(
monkeypatch: pytest.MonkeyPatch, policy: str
) -> None:
with pytest.raises(HTTPException) as exc:
await _update(
monkeypatch,
policy=policy,
data=UpdateKeyRequest(key="sk-1", max_budget=100.0),
existing=_key(max_budget=None, budget_id="shared-tier-10"),
)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_ceiling_cannot_leave_a_key_uncapped_when_an_upperbound_is_configured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("litellm.upperbound_key_generate_params", LiteLLM_UpperboundKeyGenerateParams(max_budget=20.0))
with pytest.raises(HTTPException) as exc:
await _update(
monkeypatch,
policy="ceiling",
data=UpdateKeyRequest(key="sk-1", max_budget=None),
existing=_key(max_budget=10.0),
)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_ceiling_checks_the_retained_max_budget_when_only_windows_change(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with pytest.raises(HTTPException) as exc:
await _update(
monkeypatch,
policy="ceiling",
data=UpdateKeyRequest(key="sk-1", budget_limits=[]),
existing=_key(max_budget=100.0, budget_limits=[_WEEKLY]),
caller=_caller(max_budget=20.0),
)
assert exc.value.status_code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize("policy", [None, "disabled"])
async def test_owner_cannot_clear_max_budget_by_default(monkeypatch: pytest.MonkeyPatch, policy: str | None) -> None:
with pytest.raises(HTTPException) as exc:
await _update(
monkeypatch,
policy=policy,
data=UpdateKeyRequest(key="sk-1", max_budget=None),
existing=_key(max_budget=10.0),
)
assert exc.value.status_code == 403

View file

@ -2513,6 +2513,51 @@ async def test_prepare_key_update_data_budget_limits_serializes_windows():
assert windows[0]["reset_at"] is not None
def _key_with_weekly_window(reset_at: datetime) -> LiteLLM_VerificationToken:
return LiteLLM_VerificationToken(
token="test-token",
user_id="test-user",
metadata={},
budget_limits=[{"budget_duration": "7d", "max_budget": 50.0, "reset_at": reset_at.isoformat()}],
)
async def _updated_windows_by_duration(
existing_key: LiteLLM_VerificationToken, budget_limits: list[dict[str, object]]
) -> dict[str, dict[str, object]]:
result: Final = await prepare_key_update_data(
data=UpdateKeyRequest.model_validate({"key": "test-token", "budget_limits": budget_limits}),
existing_key_row=existing_key,
)
return {window["budget_duration"]: window for window in json.loads(result["budget_limits"])}
@pytest.mark.asyncio
@pytest.mark.parametrize("weekly_cap", [50.0, 40.0, 60.0])
async def test_prepare_key_update_data_keeps_a_resubmitted_window_mid_period(weekly_cap: float) -> None:
open_reset_at: Final = datetime.now(timezone.utc) + timedelta(days=3)
windows: Final = await _updated_windows_by_duration(
_key_with_weekly_window(open_reset_at),
[{"budget_duration": "7d", "max_budget": weekly_cap}, {"budget_duration": "1d", "max_budget": 5.0}],
)
assert datetime.fromisoformat(windows["7d"]["reset_at"]) == open_reset_at
assert windows["7d"]["max_budget"] == weekly_cap
assert datetime.fromisoformat(windows["1d"]["reset_at"]) > datetime.now(timezone.utc)
@pytest.mark.asyncio
async def test_prepare_key_update_data_restarts_a_resubmitted_window_whose_period_ended() -> None:
stale_reset_at: Final = datetime.now(timezone.utc) - timedelta(days=1)
windows: Final = await _updated_windows_by_duration(
_key_with_weekly_window(stale_reset_at), [{"budget_duration": "7d", "max_budget": 50.0}]
)
assert datetime.fromisoformat(windows["7d"]["reset_at"]) > datetime.now(timezone.utc)
@pytest.mark.asyncio
async def test_prepare_key_update_data_disable_global_guardrails_false_no_premium(
monkeypatch,

View file

@ -2,6 +2,7 @@ import inspect
import asyncio
import contextlib
import json
import socket
from collections.abc import Iterator, Mapping
from types import SimpleNamespace
from typing import Dict, Final, Optional
@ -27,11 +28,13 @@ from litellm.proxy._types import (
)
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
from litellm.proxy.management_endpoints.model_management_endpoints import (
KubernetesPodsResponse,
ModelManagementAuthChecks,
_get_team_deployments,
_raise_if_rate_limits_required_but_missing,
clear_cache,
delete_team_models,
get_kubernetes_pods,
patch_model,
update_model,
)
@ -44,6 +47,210 @@ async def _passthrough_row(update_data):
return update_data
def _kubernetes_pod_router(
discovery_enabled: bool | None = True,
api_base: str = "http://vllm-headless.ns.svc.cluster.local:8000/v1",
) -> Router:
return Router(
model_list=[
{
"model_name": "gpu-llama",
"litellm_params": {
"model": "openai/test-model",
"api_base": api_base,
"api_key": "not-in-response",
**({"kubernetes_pod_discovery": discovery_enabled} if discovery_enabled is not None else {}),
},
"model_info": {"id": "model-pods-1"},
}
],
ignore_invalid_deployments=True,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"user_role",
[LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY],
)
async def test_get_kubernetes_pods_returns_sorted_read_only_response(
monkeypatch: pytest.MonkeyPatch,
user_role: LitellmUserRoles,
) -> None:
from litellm.proxy import proxy_server
router: Final = _kubernetes_pod_router()
loop: Final = asyncio.get_running_loop()
async def getaddrinfo(
host: str | None,
port: str | int | None,
family: int = 0,
type: int = 0,
proto: int = 0,
flags: int = 0,
) -> list[tuple[socket.AddressFamily, socket.SocketKind, int, str, tuple[str, int]]]:
return [
(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("10.0.0.3", 8000)),
(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("10.0.0.1", 8000)),
(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("10.0.0.1", 8000)),
]
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(loop, "getaddrinfo", getaddrinfo)
response: Final = await get_kubernetes_pods(
model_id="model-pods-1",
user_api_key_dict=UserAPIKeyAuth(user_id="proxy-admin", user_role=user_role),
)
assert isinstance(response, KubernetesPodsResponse)
assert response.model_dump() == {
"model_id": "model-pods-1",
"service_host": "vllm-headless.ns.svc.cluster.local",
"port": 8000,
"pod_ips": ("10.0.0.1", "10.0.0.3"),
"pod_count": 2,
"error": None,
}
@pytest.mark.asyncio
async def test_get_kubernetes_pods_returns_dns_error_without_exposing_deployment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.proxy import proxy_server
router: Final = _kubernetes_pod_router()
loop: Final = asyncio.get_running_loop()
async def getaddrinfo(
host: str | None,
port: str | int | None,
family: int = 0,
type: int = 0,
proto: int = 0,
flags: int = 0,
) -> list[tuple[socket.AddressFamily, socket.SocketKind, int, str, tuple[str, int]]]:
raise OSError("temporary DNS failure")
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(loop, "getaddrinfo", getaddrinfo)
response: Final = await get_kubernetes_pods(
model_id="model-pods-1",
user_api_key_dict=UserAPIKeyAuth(user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert response.model_dump() == {
"model_id": "model-pods-1",
"service_host": "vllm-headless.ns.svc.cluster.local",
"port": 8000,
"pod_ips": (),
"pod_count": 0,
"error": "temporary DNS failure",
}
@pytest.mark.asyncio
async def test_get_kubernetes_pods_returns_404_for_unknown_model(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", _kubernetes_pod_router())
with pytest.raises(ProxyException) as error:
await get_kubernetes_pods(
model_id="unknown",
user_api_key_dict=UserAPIKeyAuth(user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert error.value.code == "404"
assert error.value.type == "not_found_error"
assert error.value.param == "model_id"
@pytest.mark.asyncio
async def test_get_kubernetes_pods_returns_500_without_router(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", None)
with pytest.raises(ProxyException) as error:
await get_kubernetes_pods(
model_id="model-pods-1",
user_api_key_dict=UserAPIKeyAuth(user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert error.value.code == "500"
assert error.value.type == "internal_server_error"
@pytest.mark.asyncio
async def test_get_kubernetes_pods_returns_400_when_discovery_is_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", _kubernetes_pod_router(discovery_enabled=None))
with pytest.raises(ProxyException) as error:
await get_kubernetes_pods(
model_id="model-pods-1",
user_api_key_dict=UserAPIKeyAuth(user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert error.value.code == "400"
assert error.value.message == "Kubernetes pod discovery is not enabled for this model"
assert error.value.param == "model_id"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"api_base",
[
"https://vllm-headless.ns.svc.cluster.local:8000/v1",
"http://192.0.2.10:8000/v1",
],
)
async def test_get_kubernetes_pods_returns_400_for_ineligible_api_base(
monkeypatch: pytest.MonkeyPatch,
api_base: str,
) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", _kubernetes_pod_router(api_base=api_base))
expected_message: Final = (
"Kubernetes pod discovery is enabled for this model, but its api_base is not an http:// "
"service hostname, so no pods are resolved"
)
with pytest.raises(ProxyException) as error:
await get_kubernetes_pods(
model_id="model-pods-1",
user_api_key_dict=UserAPIKeyAuth(user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert error.value.code == "400"
assert error.value.message == expected_message
assert error.value.param == "model_id"
@pytest.mark.asyncio
async def test_get_kubernetes_pods_rejects_internal_users(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "llm_router", _kubernetes_pod_router())
with pytest.raises(ProxyException) as error:
await get_kubernetes_pods(
model_id="model-pods-1",
user_api_key_dict=UserAPIKeyAuth(user_id="internal-user", user_role=LitellmUserRoles.INTERNAL_USER),
)
assert error.value.code == "403"
assert error.value.type == "auth_error"
@pytest.mark.asyncio
@pytest.mark.parametrize("model", ["openai/test-model", "auto_router/complexity_router"])
@pytest.mark.parametrize("team_role", ["admin", "user"])

View file

@ -3748,7 +3748,18 @@ class TestSpendLogsPayload:
}
)
differences = _compare_nested_dicts(payload, expected_payload, ignore_keys=ignored_keys)
expected_payload_with_pod_routing: Final[SpendLogsPayload] = {
**expected_payload,
"metadata": expected_payload["metadata"].replace(
'"routing_decision": null, ',
'"routing_decision": null, "kubernetes_pod_routing": null, ',
),
}
differences = _compare_nested_dicts(
payload,
expected_payload_with_pod_routing,
ignore_keys=ignored_keys,
)
if differences:
pytest.fail(f"Dictionary mismatch: {differences}")

View file

@ -14,7 +14,7 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict
import litellm
import litellm.constants as litellm_constants
import litellm.proxy.spend_tracking.spend_tracking_utils as spend_tracking_utils
from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, LITTELM_CLI_SERVICE_ACCOUNT_NAME, LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, MAX_SPEND_LOG_MODEL_NAME_LENGTH, REDACTED_BY_LITELM_STRING, SESSION_ID_GENERATED_METADATA_KEY, SESSION_ID_OMITTED_METADATA_KEY, UNKNOWN_MODEL_SPEND_LOG_MODEL
from litellm.constants import KUBERNETES_POD_ROUTING_KEY, LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, LITTELM_CLI_SERVICE_ACCOUNT_NAME, LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, MAX_SPEND_LOG_MODEL_NAME_LENGTH, REDACTED_BY_LITELM_STRING, SESSION_ID_GENERATED_METADATA_KEY, SESSION_ID_OMITTED_METADATA_KEY, UNKNOWN_MODEL_SPEND_LOG_MODEL
from litellm.constants import CALLER_SESSION_ID_METADATA_KEY, INTERNAL_CALL_ORIGIN_METADATA_KEY, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.llms.base_llm.ocr.transformation import OCRResponse, OCRUsageInfo
@ -76,6 +76,89 @@ def _get_additional_usage_values_for_usage(usage: litellm.Usage) -> dict:
return metadata["additional_usage_values"]
@pytest.mark.parametrize(
("metadata", "litellm_metadata", "expected"),
[
(
{
"kubernetes_pod_routing": {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.1",
"pod_count": 3,
"selection": "round_robin",
}
},
{},
{
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.1",
"pod_count": 3,
"selection": "round_robin",
},
),
(
{},
{
"kubernetes_pod_routing": {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.2",
"pod_count": 3,
"selection": "session_affinity",
}
},
{
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.2",
"pod_count": 3,
"selection": "session_affinity",
},
),
(
{
"kubernetes_pod_routing": {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.1",
"pod_count": 3,
"selection": "round_robin",
}
},
{
"kubernetes_pod_routing": {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.3",
"pod_count": 3,
"selection": "session_affinity_retry",
}
},
{
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.3",
"pod_count": 3,
"selection": "session_affinity_retry",
},
),
],
)
def test_get_logging_payload_includes_kubernetes_pod_routing(
metadata: dict[str, object],
litellm_metadata: dict[str, object],
expected: dict[str, object],
) -> None:
now: Final = datetime.datetime(2026, 1, 1, tzinfo=timezone.utc)
payload: Final = get_logging_payload(
kwargs={
"model": "gpt-4o-mini",
"litellm_params": {"metadata": metadata, "litellm_metadata": litellm_metadata},
},
response_obj=litellm.ModelResponse(id="chatcmpl-test", choices=[]),
start_time=now,
end_time=now,
)
assert json.loads(payload["metadata"])[KUBERNETES_POD_ROUTING_KEY] == expected
assert _get_spend_logs_metadata(None)["kubernetes_pod_routing"] is None
@pytest.mark.parametrize("store_prompts,redact", [(True, False), (False, False), (True, True)])
def test_classifier_audit_spend_storage_obeys_privacy_and_truncation(monkeypatch, store_prompts, redact):
from litellm.proxy import proxy_server

View file

@ -3309,12 +3309,11 @@ def test_add_litellm_metadata_from_request_headers_x_litellm_trace_id_sets_chain
assert data["litellm_trace_id"] == "foo"
@pytest.mark.parametrize(("correlation_in_logs", "expected_spend_log_session"), [(True, "convo-1"), (False, None)])
@pytest.mark.parametrize("correlation_in_logs", [True, False], ids=["correlation-on", "correlation-off"])
@pytest.mark.parametrize("session_in_metadata", [False, True], ids=["top-level", "metadata"])
def test_x_litellm_trace_id_keeps_the_body_session_for_spend_logs_only(
def test_x_litellm_trace_id_keeps_the_body_session_beside_it_whatever_the_log_setting(
monkeypatch: pytest.MonkeyPatch,
correlation_in_logs: bool,
expected_spend_log_session: str | None,
session_in_metadata: bool,
):
monkeypatch.setattr(litellm, "request_correlation_in_logs", correlation_in_logs)
@ -3325,7 +3324,7 @@ def test_x_litellm_trace_id_keeps_the_body_session_for_spend_logs_only(
LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers(
headers={"x-litellm-trace-id": "req-trace-1"}, data=data, _metadata_variable_name="metadata"
)
assert metadata.get(CALLER_SESSION_ID_METADATA_KEY) == expected_spend_log_session
assert metadata.get(CALLER_SESSION_ID_METADATA_KEY) == "convo-1"
assert data["litellm_session_id"] == "req-trace-1"
assert metadata["session_id"] == "req-trace-1"
assert data["litellm_trace_id"] == "req-trace-1"

View file

@ -1,5 +1,7 @@
import asyncio
import copy
import importlib
import itertools
import random
import time
from collections.abc import Mapping
@ -15,6 +17,7 @@ from litellm import Router
from litellm._internal_context import current_service_target
from litellm.caching.dual_cache import DualCache
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.litellm_core_utils.litellm_logging import mask_api_base_credentials
from litellm.router_utils.cooldown_cache import CooldownCache, CooldownCacheValue
from litellm.router_utils.cooldown_callbacks import router_cooldown_event_callback
from litellm.router_utils.cooldown_handlers import (
@ -44,8 +47,9 @@ from litellm.types.router import (
AllowedFailsPolicy,
DeploymentTypedDict,
LiteLLMParamsTypedDict,
RouterRateLimitError,
)
from litellm.types.utils import CredentialItem
from litellm.types.utils import CooldownDeploymentInfo, CredentialItem
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
@ -3004,3 +3008,358 @@ def test_shared_mode_upstream_429_still_cools_down_the_deployment(single_deploym
is True
)
assert _should_run_cooldown_logic(single_deployment_router, "dep-1", 429, exc) is True
REFUSAL_MESSAGES: Final = [{"role": "user", "content": "cooldown"}]
SECRET_API_BASE: Final = "https://first.example/v1?key=sk-scripted-secret"
FIRST_REFUSED: Final = CooldownDeploymentInfo(model_id="refused-1", api_base=mask_api_base_credentials(SECRET_API_BASE))
SECOND_REFUSED: Final = CooldownDeploymentInfo(model_id="refused-2", api_base=None)
THIRD_REFUSED: Final = CooldownDeploymentInfo(model_id="refused-3", api_base=None)
OTHER_TEAMS_REFUSED: Final = CooldownDeploymentInfo(model_id="refused-team-b", api_base=None)
FIRST_WILDCARD: Final = CooldownDeploymentInfo(model_id="wildcard-1", api_base="https://wildcard.example/v1")
SECOND_WILDCARD: Final = CooldownDeploymentInfo(model_id="wildcard-2", api_base=None)
ALIAS_TARGET: Final = CooldownDeploymentInfo(model_id="tier-1", api_base="https://tier.example/v1")
PROVIDER_MODEL: Final = CooldownDeploymentInfo(model_id="friendly-1", api_base=None)
EVERY_DEPLOYMENT: Final = (
"refused-1",
"refused-2",
"refused-3",
"refused-team-b",
"neighbour-1",
"wildcard-1",
"wildcard-2",
"tier-1",
"friendly-1",
)
def _cooled_router(routing_strategy: str, cooled: tuple[str, ...]) -> Router:
router: Final = Router(
routing_strategy=routing_strategy,
model_group_alias={"entry": "tier", "tier": "refused"},
model_list=[
{
"model_name": "refused",
"litellm_params": {"model": "openai/gpt-5.4", "api_base": SECRET_API_BASE, "use_in_pass_through": True},
"model_info": {"id": "refused-1"},
},
{
"model_name": "refused",
"litellm_params": {"model": "openai/gpt-5.4", "use_in_pass_through": True, "tpm": 1},
"model_info": {"id": "refused-2"},
},
{
"model_name": "refused",
"litellm_params": {"model": "openai/gpt-5.4", "tpm": 1},
"model_info": {"id": "refused-3"},
},
{
"model_name": "refused",
"litellm_params": {"model": "openai/gpt-5.4", "use_in_pass_through": True},
"model_info": {"id": "refused-team-b", "team_id": "team-b"},
},
{
"model_name": "neighbour",
"litellm_params": {"model": "openai/gpt-5.4", "api_base": "https://neighbour.example/v1"},
"model_info": {"id": "neighbour-1"},
},
{
"model_name": "openai/*",
"litellm_params": {"model": "openai/*", "api_base": "https://wildcard.example/v1"},
"model_info": {"id": "wildcard-1"},
},
{
"model_name": "openai/*",
"litellm_params": {"model": "openai/*"},
"model_info": {"id": "wildcard-2"},
},
{
"model_name": "tier",
"litellm_params": {"model": "openai/gpt-5.4", "api_base": "https://tier.example/v1"},
"model_info": {"id": "tier-1"},
},
{
"model_name": "friendly",
"litellm_params": {"model": "anthropic/claude-friendly"},
"model_info": {"id": "friendly-1"},
},
],
)
for model_id in cooled:
router.cooldown_cache.add_deployment_to_cooldown(
model_id=model_id,
original_exception=litellm.RateLimitError("scripted 429", "openai", "gpt-5.4"),
exception_status=429,
cooldown_time=60,
)
return router
def _team_request(team_id: str | None) -> dict[str, object]:
return {"metadata": {"user_api_key_team_id": team_id} if team_id else {}}
def _select(router: Router, entrypoint: str, model: str, request_kwargs: dict[str, object]) -> object:
match entrypoint:
case "async":
return asyncio.run(
router.async_get_available_deployment(
model=model, messages=REFUSAL_MESSAGES, request_kwargs=request_kwargs
)
)
case "async-pass-through":
return asyncio.run(
router.async_get_available_deployment_for_pass_through(model=model, request_kwargs=request_kwargs)
)
case "pass-through":
return router.get_available_deployment_for_pass_through(model=model, request_kwargs=request_kwargs)
case "pass-through-chat":
return router.get_available_deployment_for_pass_through(
model=model, messages=REFUSAL_MESSAGES, request_kwargs=request_kwargs
)
case _:
return router.get_available_deployment(
model=model, messages=REFUSAL_MESSAGES, request_kwargs=request_kwargs
)
ALL_COOLED: Final = (FIRST_REFUSED, SECOND_REFUSED, THIRD_REFUSED)
STRATEGY_COOLED: Final = ("refused-1", "refused-team-b", "neighbour-1")
WILDCARD_COOLED: Final = (FIRST_WILDCARD, SECOND_WILDCARD)
@pytest.mark.parametrize(
("routing_strategy", "cooled", "entrypoint", "model", "team_id", "expected"),
(
("simple-shuffle", EVERY_DEPLOYMENT, "sync", "refused", "team-a", ALL_COOLED),
("simple-shuffle", EVERY_DEPLOYMENT, "async", "refused", "team-a", ALL_COOLED),
("simple-shuffle", EVERY_DEPLOYMENT, "pass-through", "refused", "team-a", (FIRST_REFUSED, SECOND_REFUSED)),
("simple-shuffle", EVERY_DEPLOYMENT, "async-pass-through", "refused", "team-a", ALL_COOLED),
("simple-shuffle", EVERY_DEPLOYMENT, "sync", "refused", "team-b", (*ALL_COOLED, OTHER_TEAMS_REFUSED)),
("simple-shuffle", EVERY_DEPLOYMENT, "async", "refused", None, ALL_COOLED),
("cost-based-routing", STRATEGY_COOLED, "sync", "refused", "team-a", None),
("cost-based-routing", STRATEGY_COOLED, "async", "refused", "team-a", None),
("cost-based-routing", STRATEGY_COOLED, "pass-through", "refused", "team-a", None),
("simple-shuffle", EVERY_DEPLOYMENT, "sync", "openai/gpt-5.4-mini", "team-a", WILDCARD_COOLED),
("simple-shuffle", EVERY_DEPLOYMENT, "async", "openai/gpt-5.4-mini", "team-a", WILDCARD_COOLED),
("simple-shuffle", EVERY_DEPLOYMENT, "sync", "entry", "team-a", (ALIAS_TARGET,)),
("simple-shuffle", EVERY_DEPLOYMENT, "async", "entry", "team-a", (ALIAS_TARGET,)),
("simple-shuffle", EVERY_DEPLOYMENT, "sync", "anthropic/claude-friendly", "team-a", (PROVIDER_MODEL,)),
("simple-shuffle", EVERY_DEPLOYMENT, "async", "anthropic/claude-friendly", "team-a", (PROVIDER_MODEL,)),
),
ids=(
"all-cooled-sync",
"all-cooled-async",
"all-cooled-pass-through",
"all-cooled-async-pass-through",
"all-cooled-owning-team",
"all-cooled-async-no-team",
"strategy-refused-sync",
"strategy-refused-async",
"strategy-refused-pass-through",
"wildcard-sync",
"wildcard-async",
"alias-chain-sync",
"alias-chain-async",
"provider-model-sync",
"provider-model-async",
),
)
def test_no_deployment_error_names_the_cooled_deployments_the_request_could_route_to(
routing_strategy: str,
cooled: tuple[str, ...],
entrypoint: str,
model: str,
team_id: str | None,
expected: tuple[CooldownDeploymentInfo, ...] | None,
) -> None:
router: Final = _cooled_router(routing_strategy, cooled)
with pytest.raises(RouterRateLimitError) as refusal:
_select(router, entrypoint, model, _team_request(team_id))
assert refusal.value.cooldown_deployments == expected, refusal.value.cooldown_list
@pytest.mark.parametrize("entrypoint", ("sync", "async"))
def test_no_deployment_error_names_only_the_cooled_part_of_a_route_emptied_by_other_filters(entrypoint: str) -> None:
router: Final = _cooled_router("simple-shuffle", STRATEGY_COOLED)
request_kwargs: Final = {**_team_request("team-a"), "_excluded_deployment_ids": ["refused-2", "refused-3"]}
with pytest.raises(RouterRateLimitError) as refusal:
_select(router, entrypoint, "refused", request_kwargs)
assert refusal.value.cooldown_deployments == (FIRST_REFUSED,), refusal.value.cooldown_list
assert "sk-scripted-secret" not in repr(refusal.value.cooldown_deployments)
TENANT_A: Final = CooldownDeploymentInfo(model_id="tenant-a-1", api_base="https://tenant-a.example/v1")
TENANT_B: Final = CooldownDeploymentInfo(model_id="tenant-b-1", api_base="https://tenant-b.example/v1")
SHARED: Final = CooldownDeploymentInfo(model_id="shared-1", api_base="https://shared.example/v1")
EVERY_TENANT: Final = (TENANT_A, TENANT_B, SHARED)
TENANT_ROUTES: Final = ((TENANT_A, "tenant-a", "eu"), (TENANT_B, "tenant-b", "us"), (SHARED, "default", "eu"))
def _tenant_router(cooled: tuple[CooldownDeploymentInfo, ...], **router_settings: bool) -> Router:
router: Final = Router(
model_list=[
{
"model_name": "tenants",
"litellm_params": {
"model": "openai/gpt-5.4",
"api_base": deployment["api_base"],
"tags": [tag],
"region_name": region,
"use_in_pass_through": True,
},
"model_info": {"id": deployment["model_id"]},
}
for deployment, tag, region in TENANT_ROUTES
],
**router_settings,
)
for deployment in cooled:
router.cooldown_cache.add_deployment_to_cooldown(
model_id=deployment["model_id"],
original_exception=litellm.RateLimitError("scripted 429", "openai", "gpt-5.4"),
exception_status=429,
cooldown_time=60,
)
return router
TAGS_ON: Final = {"enable_tag_filtering": True}
REGION_CHECKS_ON: Final = {"enable_pre_call_checks": True}
BOTH_ON: Final = {**TAGS_ON, **REGION_CHECKS_ON}
@pytest.mark.parametrize(
("router_settings", "entrypoint", "request_kwargs", "expected"),
(
(TAGS_ON, "async", {"metadata": {"tags": ["tenant-a"]}}, (TENANT_A,)),
(TAGS_ON, "async", {"metadata": {"tags": ["tenant-b"]}}, (TENANT_B,)),
(TAGS_ON, "async", {"metadata": {}}, EVERY_TENANT),
(TAGS_ON, "async", {}, EVERY_TENANT),
(TAGS_ON, "async", {"metadata": "unparsed"}, ()),
(TAGS_ON, "async", {"litellm_metadata": {"tags": ["tenant-a"]}}, (TENANT_A,)),
(TAGS_ON, "async", {"metadata": {"tags": ["!tenant-a", "&tenant-b"]}}, (TENANT_B,)),
(TAGS_ON, "async", {"metadata": {"tags": ["!tenant-a"]}}, (TENANT_B, SHARED)),
(TAGS_ON, "async", {"metadata": {"tags": ["tenant-z"]}}, (SHARED,)),
(TAGS_ON, "async-pass-through", {"metadata": {"tags": ["tenant-a"]}}, (TENANT_A,)),
({}, "async", {"metadata": {"tags": ["tenant-a"]}, "enable_tag_filtering": True}, (TENANT_A,)),
({}, "async", {"metadata": {"tags": ["tenant-a"]}}, EVERY_TENANT),
(REGION_CHECKS_ON, "async", {"metadata": {}, "allowed_model_region": "eu"}, (TENANT_A, SHARED)),
(REGION_CHECKS_ON, "sync", {"metadata": {}, "allowed_model_region": "eu"}, (TENANT_A, SHARED)),
(REGION_CHECKS_ON, "pass-through-chat", {"metadata": {}, "allowed_model_region": "eu"}, (TENANT_A, SHARED)),
(REGION_CHECKS_ON, "pass-through", {"metadata": {}, "allowed_model_region": "eu"}, EVERY_TENANT),
({}, "sync", {"metadata": {}, "allowed_model_region": "eu"}, EVERY_TENANT),
(REGION_CHECKS_ON, "sync", {"metadata": {}, "allowed_model_region": 1}, ()),
(
{**TAGS_ON, **REGION_CHECKS_ON},
"async",
{"metadata": {"tags": ["tenant-a"]}, "allowed_model_region": "us"},
(),
),
),
ids=(
"tag-a",
"tag-b",
"untagged-falls-back-past-the-default",
"no-metadata",
"metadata-not-a-mapping",
"tag-litellm-metadata",
"tag-constraints",
"tag-exclusion-falls-back-in-tiers",
"unknown-tag-gets-default",
"tag-async-pass-through",
"tag-request-level-switch",
"tags-off",
"region-async",
"region-sync",
"region-pass-through",
"region-pass-through-without-input",
"region-without-pre-call-checks",
"region-not-a-string",
"region-and-tag-allow-none",
),
)
def test_no_deployment_error_names_only_the_cooled_deployments_tag_and_region_routing_allow(
router_settings: dict[str, bool],
entrypoint: str,
request_kwargs: dict[str, object],
expected: tuple[CooldownDeploymentInfo, ...],
) -> None:
router: Final = _tenant_router(EVERY_TENANT, **router_settings)
with pytest.raises(RouterRateLimitError) as refusal:
_select(router, entrypoint, "tenants", request_kwargs)
assert refusal.value.cooldown_deployments == expected, refusal.value.cooldown_list
def test_naming_tag_routed_deployments_leaves_the_request_metadata_untouched() -> None:
metadata: Final[dict[str, object]] = {"tags": ["tenant-a"]}
with pytest.raises(RouterRateLimitError) as refusal:
_select(_tenant_router(EVERY_TENANT, **TAGS_ON), "async", "tenants", {"metadata": metadata})
assert refusal.value.cooldown_deployments == (TENANT_A,)
assert metadata == {"tags": ["tenant-a"]}
@pytest.mark.parametrize(
("has_request_input", "expected"), ((True, (TENANT_A, SHARED)), (False, EVERY_TENANT)), ids=("chat", "no-input")
)
def test_region_routable_deployments_follow_the_pre_call_region_check(
has_request_input: bool, expected: tuple[CooldownDeploymentInfo, ...]
) -> None:
router: Final = _tenant_router((), **REGION_CHECKS_ON)
kept: Final = router._region_routable_deployments(
router.get_model_list(model_name="tenants") or [], {"allowed_model_region": "eu"}, has_request_input
)
assert router._deployment_ids(kept) == frozenset(deployment["model_id"] for deployment in expected)
def test_routable_cooled_deployment_ids_skip_healthy_deployments_and_peel_tag_fallbacks() -> None:
router: Final = _tenant_router((TENANT_A, SHARED), **TAGS_ON)
routable: Final = asyncio.run(
router._async_routable_cooled_deployment_ids(
model="tenants",
request_deployments=router.get_model_list(model_name="tenants") or [],
cooldown_deployments=(TENANT_A["model_id"], SHARED["model_id"]),
request_kwargs={"metadata": {}},
has_request_input=True,
)
)
assert routable == frozenset((TENANT_A["model_id"], SHARED["model_id"]))
REACHABILITY_REQUESTS: Final = (
{"metadata": {"tags": ["tenant-a"]}},
{"metadata": {}},
{"metadata": {"tags": ["!tenant-a"]}},
{"metadata": {"tags": ["tenant-z"]}},
{"metadata": {"tags": ["!tenant-a", "&tenant-b"]}},
{"metadata": {}, "allowed_model_region": "eu"},
{"metadata": {"tags": ["tenant-a"]}, "allowed_model_region": "us"},
)
HEALTH_STATES: Final = tuple(
itertools.chain.from_iterable(
itertools.combinations(EVERY_TENANT, size) for size in range(1, len(EVERY_TENANT) + 1)
)
)
def _routable_while_up(up: tuple[CooldownDeploymentInfo, ...], request_kwargs: dict[str, object]) -> set[str]:
router: Final = _tenant_router(tuple(deployment for deployment in EVERY_TENANT if deployment not in up), **BOTH_ON)
try:
pool: Final = asyncio.run(
router.async_get_healthy_deployments(
model="tenants", request_kwargs=copy.deepcopy(request_kwargs), messages=REFUSAL_MESSAGES
)
)
except ValueError:
return set()
return {str(deployment["model_info"]["id"]) for deployment in pool}
@pytest.mark.parametrize("request_kwargs", REACHABILITY_REQUESTS)
def test_the_refusal_names_each_cooled_deployment_some_cooldown_state_routes_the_request_to(
request_kwargs: dict[str, object],
) -> None:
reachable: Final = set().union(*(_routable_while_up(up, request_kwargs) for up in HEALTH_STATES))
with pytest.raises(RouterRateLimitError) as refusal:
_select(_tenant_router(EVERY_TENANT, **BOTH_ON), "async", "tenants", copy.deepcopy(request_kwargs))
assert {deployment["model_id"] for deployment in refusal.value.cooldown_deployments or ()} == reachable

File diff suppressed because it is too large Load diff

View file

@ -395,22 +395,25 @@ class TestGpt6AstraAdvertisesItsDocumentedLevels:
[
("azure/gpt-6-astra", "azure"),
("azure/us/gpt-6-astra", "azure"),
("azure/eu/gpt-6-astra", "azure"),
("azure_ai/gpt-6-astra", "azure_ai"),
],
)
def test_an_azure_hosted_deployment_advertises_none_but_not_max(
def test_an_azure_hosted_deployment_advertises_the_levels_both_its_routes_accept(
self, local_model_cost_map, model, custom_llm_provider
):
"""Microsoft hosts the same model with a different level set than OpenAI does. Verified live
on both Azure routes: none returns 200 with zero reasoning tokens and unlocks temperature,
which OpenAI's API rejects, while max returns 400 unsupported_value naming none through
xhigh as the levels it does take."""
"""Verified live on 2026-10-01 against a Foundry gpt-6-astra deployment (model
gpt-6-astra-2026-09-03): none returns 400 unsupported_value on chat and on Responses, naming
low, medium, high and xhigh as the levels it takes; Responses also honors max with reasoning
tokens billed while chat refuses it, as OpenAI's chat does. The flag is route-blind, so the
row keeps max off and the advertised set stops at the level every route accepts.
Earlier evidence (2026-09-05, commit e79f3ec5205) had none accepted on chat; the deployment
no longer behaves that way."""
from litellm.utils import get_model_info_helper
model_info = dict(get_model_info_helper(model=model, custom_llm_provider=custom_llm_provider))
assert resolve_supported_reasoning_efforts(model_info, deployment_is_mapped=True) == (
"none",
"low",
"medium",
"high",

View file

@ -1305,6 +1305,50 @@ def test_responses_api_bridge_check_gpt_5_4_tools_with_reasoning_none_stays_chat
assert model_info.get("mode") != "responses"
@pytest.mark.parametrize(
"model, custom_llm_provider, reasoning_effort",
[
("gpt-6.1-sol", "openai", "none"),
("gpt-6.1-sol", "openai", {"effort": "none"}),
("gpt-6-astra", "azure", "none"),
],
)
def test_responses_api_bridge_check_tools_with_a_none_the_row_refuses_routes_to_responses(
local_model_cost_map: None, model: str, custom_llm_provider: str, reasoning_effort: object
) -> None:
"""The ``none`` escape hatch only holds when the chat gate forwards it. These rows refuse
``none``, so the gate drops it (or refuses the call) and the provider runs its default effort,
under which Chat Completions rejects function tools; the bridge has to fire the way it does
for an unset effort. Row-derived: the test follows the flag, never the vendor's answer."""
from litellm.main import responses_api_bridge_check
row_key: Final = model if custom_llm_provider == "openai" else f"{custom_llm_provider}/{model}"
assert litellm.model_cost[row_key]["supports_none_reasoning_effort"] is False
model_info, _ = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
tools=[{"type": "function", "function": {"name": "get_capital"}}],
reasoning_effort=reasoning_effort,
)
assert model_info.get("mode") == "responses"
def test_responses_api_bridge_check_a_none_the_row_refuses_stays_chat_without_tools(
local_model_cost_map: None,
) -> None:
"""Without function tools nothing forces the Responses route: the dropped ``none`` leaves a
plain chat call at the default effort, as for any other dropped level."""
from litellm.main import responses_api_bridge_check
model_info, _ = responses_api_bridge_check(
model="gpt-6.1-sol", custom_llm_provider="openai", reasoning_effort="none"
)
assert model_info.get("mode") != "responses"
def test_responses_api_bridge_check_reasoning_none_with_summary_still_routes_to_responses():
"""A reasoning summary is Responses-only regardless of effort value."""
from litellm.main import responses_api_bridge_check
@ -1712,11 +1756,15 @@ _FOUNDRY_FUNCTION_TOOL: Final = ({"type": "function", "function": {"name": "get_
pytest.param("gpt-6-astra", "https://myresource.openai.azure.com", None, id="gpt-6-azure-openai-host"),
pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, "low", id="gpt-5.6-explicit-effort"),
pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, {"effort": "high"}, id="gpt-5.6-explicit-effort-dict"),
pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "none", id="gpt-6-row-refused-none"),
],
)
def test_responses_api_bridge_check_azure_ai_foundry_rejected_tools_route_to_responses(
model_name, api_base, reasoning_effort
):
"""The explicit ``none`` only keeps a Foundry gpt-6 request on chat while the deployment takes
it; the ``azure_ai/gpt-6-astra`` row says it does not, so the gate drops it and the call runs at
the default effort, under which Foundry rejects function tools from gpt-6 on."""
from litellm.main import responses_api_bridge_check
model_info, model = responses_api_bridge_check(
@ -1734,7 +1782,6 @@ def test_responses_api_bridge_check_azure_ai_foundry_rejected_tools_route_to_res
@pytest.mark.parametrize(
"model_name, api_base, reasoning_effort",
[
pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "none", id="explicit-none-stays-chat"),
pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, None, id="gpt-5.6-unset-effort-stays-chat"),
pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, "none", id="gpt-5.6-explicit-none-stays-chat"),
pytest.param("gpt-5.5", _FOUNDRY_API_BASE, "high", id="gpt-5.5-explicit-effort-stays-chat"),

View file

@ -5,6 +5,7 @@ from typing import Final
from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet
from packaging.utils import NormalizedName, canonicalize_name
from packaging.version import Version
if sys.version_info >= (3, 11):
import tomllib
@ -18,6 +19,8 @@ OPTIONAL_DEPENDENCIES: Final = PYPROJECT["project"]["optional-dependencies"]
AIOHTTP_POOL_POISONING_RANGE: Final = SpecifierSet(">=3.14.0,<3.14.2")
AIOHTTP_POOL_POISONING_RELEASES: Final = ("3.14.0", "3.14.1")
AIOHTTP_POOL_POISONING_SOURCE: Final = "aio-libs/aiohttp#12953, fixed in aiohttp 3.14.2"
ANYIO_STRANDED_LOCK_WAITER_FIX: Final = Version("4.14.0")
ANYIO_STRANDED_LOCK_WAITER_SOURCE: Final = "agronholm/anyio#1145, fixed in anyio 4.14.0 released 2026-06-15"
CLI_RUNTIME_DEPENDENCIES: Final = frozenset({"rich", "pyyaml", "requests"})
PROXY_SERVER_ONLY_DEPENDENCIES: Final = frozenset(
{
@ -92,3 +95,17 @@ def test_locked_aiohttp_version_is_not_pool_poisoning() -> None:
locked: Final = _locked_package("aiohttp")["version"]
assert isinstance(locked, str)
assert not AIOHTTP_POOL_POISONING_RANGE.contains(locked), AIOHTTP_POOL_POISONING_SOURCE
def test_declared_anyio_floor_is_at_or_above_stranded_lock_waiter_fix() -> None:
declared: Final = tuple(
Requirement(requirement)
for requirement in PYPROJECT["project"]["dependencies"]
if canonicalize_name(Requirement(requirement).name) == "anyio"
)
assert len(declared) == 1, ANYIO_STRANDED_LOCK_WAITER_SOURCE
assert declared[0].marker is None, ANYIO_STRANDED_LOCK_WAITER_SOURCE
assert any(
specifier.operator == ">=" and Version(specifier.version) >= ANYIO_STRANDED_LOCK_WAITER_FIX
for specifier in declared[0].specifier
), ANYIO_STRANDED_LOCK_WAITER_SOURCE

View file

@ -9978,6 +9978,94 @@ def test_update_kwargs_with_deployment_model_info_in_metadata():
assert model_info["output_cost_per_token"] == 0.0015
def test_update_kwargs_with_deployment_clears_pod_routing_on_non_discovery_fallback() -> None:
from litellm.constants import KUBERNETES_POD_ROUTING_KEY
routing_record: Final = {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.1",
"pod_count": 3,
"selection": "session_affinity",
}
discovery_deployment: Final = {
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake"},
"model_info": {"id": "discovery-id"},
KUBERNETES_POD_ROUTING_KEY: routing_record,
}
fallback_deployment: Final = {
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake"},
"model_info": {"id": "fallback-id"},
}
router: Final = litellm.Router(model_list=[discovery_deployment])
kwargs: Final = {"metadata": {}}
router._update_kwargs_with_deployment(deployment=discovery_deployment, kwargs=kwargs)
assert kwargs["metadata"][KUBERNETES_POD_ROUTING_KEY] == routing_record
router._update_kwargs_with_deployment(deployment=fallback_deployment, kwargs=kwargs)
assert kwargs["metadata"][KUBERNETES_POD_ROUTING_KEY] is None
def test_update_kwargs_with_deployment_synchronizes_existing_litellm_metadata_pod_routing() -> None:
from litellm.constants import KUBERNETES_POD_ROUTING_KEY
routing_record: Final = {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.1",
"pod_count": 3,
"selection": "session_affinity",
}
spoofed_record: Final = {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.9",
"pod_count": 3,
"selection": "round_robin",
}
deployment: Final = {
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake"},
"model_info": {"id": "discovery-id"},
KUBERNETES_POD_ROUTING_KEY: routing_record,
}
original_litellm_metadata: Final = {KUBERNETES_POD_ROUTING_KEY: spoofed_record}
kwargs: Final = {"metadata": {}, "litellm_metadata": original_litellm_metadata}
router: Final = litellm.Router(model_list=[deployment])
router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name="completion")
assert kwargs["metadata"][KUBERNETES_POD_ROUTING_KEY] == routing_record
assert kwargs["litellm_metadata"][KUBERNETES_POD_ROUTING_KEY] == routing_record
assert kwargs["litellm_metadata"] is not original_litellm_metadata
assert original_litellm_metadata[KUBERNETES_POD_ROUTING_KEY] == spoofed_record
def test_update_kwargs_with_non_discovery_deployment_clears_other_pod_routing_bucket() -> None:
from litellm.constants import KUBERNETES_POD_ROUTING_KEY
previous_record: Final = {
"service_host": "vllm-headless.ns.svc.cluster.local",
"pod_ip": "10.0.0.1",
"pod_count": 3,
"selection": "session_affinity",
}
deployment: Final = {
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake"},
"model_info": {"id": "fallback-id"},
}
original_litellm_metadata: Final = {KUBERNETES_POD_ROUTING_KEY: previous_record}
kwargs: Final = {"metadata": {}, "litellm_metadata": original_litellm_metadata}
router: Final = litellm.Router(model_list=[deployment])
router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name="completion")
assert kwargs["metadata"][KUBERNETES_POD_ROUTING_KEY] is None
assert kwargs["litellm_metadata"][KUBERNETES_POD_ROUTING_KEY] is None
assert original_litellm_metadata[KUBERNETES_POD_ROUTING_KEY] == previous_record
def test_combine_fallback_usage():
"""Test that _combine_fallback_usage merges partial and fallback usage."""
from litellm.router import Router

View file

@ -131,6 +131,7 @@ OPTION_NAMES: Final = (
"use_litellm_proxy",
"use_chat_completions_api",
"use_in_pass_through",
"kubernetes_pod_discovery",
"allowed_openai_params",
"fallbacks",
"context_window_fallback_dict",

View file

@ -132,6 +132,7 @@ const alwaysMounted = {
const advancedOpenExtras = {
guardrails: undefined,
kubernetes_pod_discovery: undefined,
tags: undefined,
use_in_pass_through: undefined,
vector_store_ids: undefined,
@ -304,6 +305,24 @@ describe("AddModelPanel submit payload contract", () => {
});
});
it("submits Kubernetes pod discovery in litellm_params", async () => {
const { user, openAdvanced, fillRequired, submit } = await setup();
await fillRequired();
await openAdvanced();
await user.click(screen.getByRole("switch", { name: "Kubernetes pod discovery" }));
await submit();
expect(lastCreatedModel()).toStrictEqual({
model_name: "gpt-4o",
litellm_params: {
...alwaysMounted,
...advancedOpenExtras,
kubernetes_pod_discovery: true,
},
model_info: { ...baseModelInfo },
});
});
it("drops a collapsed section's keys and the value typed into it", async () => {
const { user, openAdvanced, closeAdvanced, fillRequired, submit } = await setup();
await fillRequired();

View file

@ -1,6 +1,8 @@
import { act, fireEvent, render, waitFor, screen } from "@testing-library/react";
import { useFormContext } from "react-hook-form";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MountedFormHost } from "../../../tests/mounted-form-host";
import type { MountedFormValues } from "../common_components/MountedFormField";
import AdvancedSettings from "./advanced_settings";
const mockUsePtuCostAttributionEnabled = vi.fn();
@ -11,7 +13,16 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled", () =>
const PTU_LABELS = ["PTU Count", "Calculated Cost per PTU / Hour (USD)", "PTU Effective From (UTC)"];
const renderAdvancedSettings = () =>
const KubernetesPodDiscoveryValue = () => {
const form = useFormContext<MountedFormValues>();
return (
<output aria-label="Kubernetes pod discovery form value" role="status">
{String(form.watch("kubernetes_pod_discovery") === true)}
</output>
);
};
const renderAdvancedSettings = (includeDiscoveryValue = false) =>
render(
<MountedFormHost>
<AdvancedSettings
@ -21,6 +32,7 @@ const renderAdvancedSettings = () =>
tagsList={{}}
accessToken="test-token"
/>
{includeDiscoveryValue && <KubernetesPodDiscoveryValue />}
</MountedFormHost>,
);
@ -34,6 +46,23 @@ describe("AdvancedSettings", () => {
renderAdvancedSettings();
});
it("updates the Kubernetes pod discovery form value when enabled", async () => {
renderAdvancedSettings(true);
act(() => {
fireEvent.click(screen.getByText("Advanced Settings"));
});
const toggle = await screen.findByRole("switch", { name: "Kubernetes pod discovery" });
const formValue = screen.getByRole("status", { name: "Kubernetes pod discovery form value" });
expect(toggle).not.toBeChecked();
expect(formValue).toHaveTextContent("false");
fireEvent.click(toggle);
expect(toggle).toBeChecked();
expect(formValue).toHaveTextContent("true");
});
it("should render tags list", async () => {
renderAdvancedSettings();
fireEvent.click(screen.getByText("Advanced Settings"));

View file

@ -457,6 +457,20 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
)}
</MountedFormField>
<MountedFormField
name="kubernetes_pod_discovery"
label={labelWithHint(
"Kubernetes pod discovery",
"Treat the API Base hostname as a Kubernetes headless service: resolve it to its ready pod IPs " +
"and spread requests across the pods. LiteLLM must run inside the same cluster.",
)}
className="mb-4"
>
{(control) => (
<Switch id={control.id} checked={control.value === true} onCheckedChange={control.onChange} />
)}
</MountedFormField>
<MountedFormField
name="cache_control"
label={labelWithHint(CACHE_CONTROL_LABEL, CACHE_CONTROL_TOOLTIP)}

View file

@ -33,6 +33,12 @@ export const CREDENTIAL_LABELS: Record<string, string> = {
false: "Configured key",
};
export const KUBERNETES_POD_ROUTING_LABELS = {
round_robin: "Round robin",
session_affinity: "Session affinity",
session_affinity_retry: "Session affinity (retry)",
} as const;
export const QUICK_SELECT_OPTIONS: { label: string; value: number; unit: string }[] = [
{ label: "Last Minute", value: 1, unit: "minutes" },
{ label: "Last 15 Minutes", value: 15, unit: "minutes" },

View file

@ -431,6 +431,37 @@ describe("LogDetailContent", () => {
expect(screen.queryByText("Configured key")).not.toBeInTheDocument();
});
it.each([
{ selection: "round_robin", label: "Round robin" },
{ selection: "session_affinity", label: "Session affinity" },
{ selection: "session_affinity_retry", label: "Session affinity (retry)" },
] as const)("shows the selected Kubernetes pod and routing mode", ({ selection, label }) => {
render(
<LogDetailContent
logEntry={createLogEntry({
metadata: {
status: "success",
kubernetes_pod_routing: {
service_host: "vllm-headless.ns.svc.cluster.local",
pod_ip: "10.0.0.2",
pod_count: 3,
selection,
},
},
})}
/>,
);
expect(screen.getByText("Pod")).toBeInTheDocument();
expect(screen.getByText(`10.0.0.2 · ${label} · 3 pods`)).toBeInTheDocument();
});
it("omits the Pod row when routing metadata is absent", () => {
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success" } })} />);
expect(screen.queryByText("Pod")).not.toBeInTheDocument();
});
it("should display guardrail label when guardrail data exists", () => {
render(
<LogDetailContent

View file

@ -23,7 +23,7 @@ import {
import { CostBreakdownViewer } from "./sections/CostBreakdownViewer";
import { ConfigInfoMessage } from "./sections/ConfigInfoMessage";
import { VectorStoreViewer } from "./sections/VectorStoreViewer";
import { CREDENTIAL_LABELS } from "../constants";
import { CREDENTIAL_LABELS, KUBERNETES_POD_ROUTING_LABELS } from "../constants";
import { TruncatedValue } from "./TruncatedValue";
import { TokenFlow } from "./TokenFlow";
import { JsonViewer } from "./JsonViewer";
@ -75,6 +75,7 @@ export function LogDetailContent({
userEmail,
}: LogDetailContentProps) {
const metadata = logEntry.metadata || {};
const podRouting = metadata.kubernetes_pod_routing;
const hasError = metadata.status === "failure";
const errorInfo = hasError ? metadata.error_information : null;
const isClassifier =
@ -160,6 +161,15 @@ export function LogDetailContent({
<DescriptionItem label="API Base">
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
</DescriptionItem>
{podRouting && typeof podRouting === "object" && (
<DescriptionItem label="Pod">
{podRouting.pod_ip}
{" · "}
{KUBERNETES_POD_ROUTING_LABELS[podRouting.selection]}
{" · "}
{podRouting.pod_count} pods
</DescriptionItem>
)}
{logEntry.requester_ip_address && (
<DescriptionItem label="IP Address">{logEntry.requester_ip_address}</DescriptionItem>
)}

View file

@ -10,6 +10,13 @@ export const LOGS_SORT_FIELD_MAP = {
export type LogsSortField = keyof typeof LOGS_SORT_FIELD_MAP;
export type KubernetesPodRouting = {
service_host: string;
pod_ip: string;
pod_count: number;
selection: "round_robin" | "session_affinity" | "session_affinity_retry";
};
export type LogEntry = {
request_id: string;
litellm_call_id?: string | null;
@ -29,7 +36,9 @@ export type LogEntry = {
user?: string;
end_user?: string;
custom_llm_provider?: string;
metadata?: Record<string, any>;
metadata?: Record<string, any> & {
kubernetes_pod_routing?: KubernetesPodRouting | null;
};
cache_hit: string;
cache_key?: string;
request_tags?: Record<string, any>;

View file

@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, testQueryClient } from "@/../tests/test-utils";
import KubernetesPodsCard from "./KubernetesPodsCard";
const podResponse = {
model_id: "model-pods-1",
service_host: "vllm-headless.ns.svc.cluster.local",
port: 8000,
pod_ips: ["10.0.0.1", "10.0.0.2", "10.0.0.3"],
pod_count: 3,
error: null,
};
const lookupErrorResponse = {
...podResponse,
pod_ips: [],
pod_count: 0,
error: "temporary DNS failure",
};
const network = vi.fn<typeof fetch>();
describe("KubernetesPodsCard", () => {
beforeEach(() => {
testQueryClient.clear();
network.mockReset().mockResolvedValue(Response.json(podResponse));
vi.stubGlobal("fetch", network);
});
afterEach(() => {
vi.unstubAllGlobals();
testQueryClient.clear();
});
it("shows the service host, ready count, and all pod IPs", async () => {
renderWithProviders(<KubernetesPodsCard modelId="model-pods-1" />);
expect(await screen.findByText("vllm-headless.ns.svc.cluster.local:8000")).toBeInTheDocument();
expect(screen.getByText("3 ready pods")).toBeInTheDocument();
expect(screen.getByText("10.0.0.1")).toBeInTheDocument();
expect(screen.getByText("10.0.0.2")).toBeInTheDocument();
expect(screen.getByText("10.0.0.3")).toBeInTheDocument();
});
it("shows an empty state when no pods are ready", async () => {
network.mockResolvedValueOnce(
Response.json({
...podResponse,
pod_ips: [],
pod_count: 0,
}),
);
renderWithProviders(<KubernetesPodsCard modelId="model-pods-1" />);
expect(await screen.findByText("No ready pods")).toBeInTheDocument();
expect(screen.queryByText("0 ready pods")).not.toBeInTheDocument();
});
it("shows a pod lookup error", async () => {
network.mockResolvedValueOnce(Response.json(lookupErrorResponse));
renderWithProviders(<KubernetesPodsCard modelId="model-pods-1" />);
expect(await screen.findByRole("alert")).toHaveTextContent("temporary DNS failure");
expect(screen.queryByText("0 ready pods")).not.toBeInTheDocument();
});
it("shows an API error", async () => {
network.mockResolvedValueOnce(Response.json({ detail: "DNS lookup unavailable" }, { status: 503 }));
renderWithProviders(<KubernetesPodsCard modelId="model-pods-1" />);
expect(await screen.findByRole("alert")).toHaveTextContent("DNS lookup unavailable");
});
it("shows the API message for an ineligible Kubernetes service host", async () => {
const message =
"Kubernetes pod discovery is enabled for this model, but its api_base is not an http:// " +
"service hostname, so no pods are resolved";
network.mockResolvedValueOnce(
Response.json(
{
error: {
message,
type: "bad_request_error",
param: "model_id",
code: "400",
},
},
{ status: 400 },
),
);
renderWithProviders(<KubernetesPodsCard modelId="model-pods-1" />);
expect(await screen.findByRole("alert")).toHaveTextContent(message);
});
it("refreshes the pod lookup when requested", async () => {
const user: ReturnType<typeof userEvent.setup> = userEvent.setup();
renderWithProviders(<KubernetesPodsCard modelId="model-pods-1" />);
expect(await screen.findByText("10.0.0.1")).toBeInTheDocument();
network.mockResolvedValueOnce(
Response.json({
...podResponse,
pod_ips: ["10.0.0.4"],
pod_count: 1,
}),
);
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(await screen.findByText("10.0.0.4")).toBeInTheDocument();
expect(network).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,89 @@
"use client";
import { $api } from "@/lib/http/api";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import type { components } from "@/lib/http/schema";
interface KubernetesPodsCardProps {
modelId: string;
}
interface KubernetesPodsCardContentProps {
data: components["schemas"]["KubernetesPodsResponse"] | undefined;
isError: boolean;
isPending: boolean;
apiErrorMessage: string;
}
function KubernetesPodsCardContent({ data, isError, isPending, apiErrorMessage }: KubernetesPodsCardContentProps) {
const hasData = data !== undefined && !isError;
const hasLookupError = hasData && data.error !== null;
const showEmptyState = hasData && data.error === null && data.pod_ips.length === 0;
const showPodIps = hasData && data.error === null && data.pod_ips.length > 0;
const podCount = hasData && !hasLookupError ? data.pod_count : null;
return (
<>
{podCount !== null && podCount > 0 && <p className="mt-4 text-sm">{podCount} ready pods</p>}
{isPending && (
<p className="mt-4 text-sm text-muted-foreground" role="status">
Loading Kubernetes pods
</p>
)}
{isError && (
<p className="mt-4 text-sm text-destructive" role="alert">
{apiErrorMessage}
</p>
)}
{data && hasLookupError && (
<p className="mt-4 text-sm text-destructive" role="alert">
{data.error}
</p>
)}
{data && showEmptyState && <p className="mt-4 text-sm text-muted-foreground">No ready pods</p>}
{data && showPodIps && (
<ul aria-label="Ready Kubernetes pod IPs" className="mt-3 space-y-1 font-mono text-sm">
{data.pod_ips.map((ip) => (
<li key={ip}>{ip}</li>
))}
</ul>
)}
</>
);
}
export default function KubernetesPodsCard({ modelId }: KubernetesPodsCardProps) {
const { data, error, isError, isPending, refetch } = $api.useQuery(
"get",
"/model/{model_id}/kubernetes_pods",
{ params: { path: { model_id: modelId } } },
{ refetchInterval: 10_000 },
);
const apiErrorMessage =
error instanceof Error ? error.message : JSON.stringify(error) ?? "Failed to load Kubernetes pods";
return (
<Card className="block p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-medium">Kubernetes pods</h3>
{data && (
<p className="mt-1 text-sm text-muted-foreground">
{data.port === null ? data.service_host : `${data.service_host}:${data.port}`}
</p>
)}
</div>
<Button type="button" variant="outline" onClick={() => void refetch()}>
Refresh
</Button>
</div>
<KubernetesPodsCardContent
data={data}
isError={isError}
isPending={isPending}
apiErrorMessage={apiErrorMessage}
/>
</Card>
);
}

View file

@ -6,6 +6,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import ModelInfoView from "./model_info_view";
import { toast } from "@/lib/toast";
import * as networking from "./networking";
vi.mock("./model_info/KubernetesPodsCard", () => ({
default: ({ modelId }: { modelId: string }) => <div data-testid="kubernetes-pods-card">{modelId}</div>,
}));
vi.mock(
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
async () => await import("../../tests/mocks/complexityScorerDefaults"),
@ -196,6 +199,91 @@ describe("ModelInfoView", () => {
});
});
it("shows Kubernetes pods for discovery-enabled models", async () => {
mockUseModelsInfo.mockReturnValue({
data: {
data: [
{
...defaultModelData,
litellm_params: { ...defaultModelData.litellm_params, kubernetes_pod_discovery: true },
},
],
},
isLoading: false,
error: null,
});
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByTestId("kubernetes-pods-card")).toHaveTextContent("123");
});
it("shows Kubernetes pods for admin viewers", async () => {
mockUseModelsInfo.mockReturnValue({
data: {
data: [
{
...defaultModelData,
litellm_params: { ...defaultModelData.litellm_params, kubernetes_pod_discovery: true },
},
],
},
isLoading: false,
error: null,
});
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} userRole="Admin Viewer" isViewOnly={true} />, { wrapper });
expect(await screen.findByTestId("kubernetes-pods-card")).toHaveTextContent("123");
});
it("hides Kubernetes pods for non-admin users", async () => {
mockUseModelsInfo.mockReturnValue({
data: {
data: [
{
...defaultModelData,
litellm_params: { ...defaultModelData.litellm_params, kubernetes_pod_discovery: true },
},
],
},
isLoading: false,
error: null,
});
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} userRole="Internal User" />, { wrapper });
expect(await screen.findByText("Model Settings")).toBeInTheDocument();
expect(screen.queryByTestId("kubernetes-pods-card")).not.toBeInTheDocument();
});
it("hides Kubernetes pods when discovery is disabled", async () => {
mockUseModelsInfo.mockReturnValue({
data: {
data: [
{
...defaultModelData,
litellm_params: { ...defaultModelData.litellm_params, kubernetes_pod_discovery: false },
},
],
},
isLoading: false,
error: null,
});
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByText("Model Settings")).toBeInTheDocument();
expect(screen.queryByTestId("kubernetes-pods-card")).not.toBeInTheDocument();
});
it("hides Kubernetes pods when discovery is not configured", async () => {
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByText("Model Settings")).toBeInTheDocument();
expect(screen.queryByTestId("kubernetes-pods-card")).not.toBeInTheDocument();
});
it("should display loading state when model data is loading", () => {
mockUseModelsInfo.mockReturnValue({
data: null,

View file

@ -23,7 +23,7 @@ import {
isComplexityRouter as isComplexityRouterParams,
} from "./add_model/auto_router_strategies";
import { canEditAutoRouter, canModifyModel } from "@/utils/modelPermissions";
import { teamsUserCanAssign } from "@/utils/roles";
import { isProxyAdminTierRole, teamsUserCanAssign } from "@/utils/roles";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
@ -50,6 +50,7 @@ import ModelInfoEditForm, { type ModelEditSubmitValues, type TouchedPricingField
import { Tag } from "./tag_management/types";
import { getDisplayModelName } from "./view_model/model_name_display";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import KubernetesPodsCard from "./model_info/KubernetesPodsCard";
interface ModelInfoViewProps {
modelId: string;
@ -680,6 +681,12 @@ export default function ModelInfoView({
</div>
</div>
{modelData?.litellm_params?.kubernetes_pod_discovery === true && isProxyAdminTierRole(userRole ?? "") && (
<div className="mb-6">
<KubernetesPodsCard modelId={modelId} />
</div>
)}
{/* Settings Card */}
<Card className="block p-6">
<div className="flex justify-between items-center mb-4">

View file

@ -10187,6 +10187,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/model/{model_id}/kubernetes_pods": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Kubernetes Pods */
get: operations["get_kubernetes_pods_model__model_id__kubernetes_pods_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/model/{model_id}/update": {
parameters: {
query?: never;
@ -30196,6 +30213,11 @@ export interface components {
* @default false
*/
search_tool_deny_by_default: boolean;
/**
* Self Serve Budget Policy
* @description Lets a non-admin change max_budget or budget_limits on a key they created and still own via /key/update. 'disabled' (default) requires an admin for any budget change. 'lower_only' lets the owner add or lower limits. 'ceiling' also lets the owner raise or remove limits when they could create that key with the new budget via /key/generate.
*/
self_serve_budget_policy?: ("disabled" | "lower_only" | "ceiling") | null;
/** @description Daily check of the spend LiteLLM captured against the provider's own bill (OpenAI via OPENAI_ADMIN_KEY). Publishes litellm_spend_capture_rate per provider and alerts when the ratio over the lookback window falls under the threshold (default 0.9). Off unless set. */
spend_capture_rate_check?: components["schemas"]["SpendCaptureRateCheckSettings"] | null;
/** @description Which keys of LiteLLM_SpendLogs.metadata are written to the database. Set exactly one of 'include' (write only these keys) or 'exclude' (drop these keys). 'status' and 'cold_storage_object_key' are always written. Daily spend tables, budgets and logging callbacks still see every key. Unset writes every key */
@ -33764,6 +33786,21 @@ export interface components {
*/
tier: string;
};
/** KubernetesPodsResponse */
KubernetesPodsResponse: {
/** Error */
error: string | null;
/** Model Id */
model_id: string;
/** Pod Count */
pod_count: number;
/** Pod Ips */
pod_ips: string[];
/** Port */
port: number | null;
/** Service Host */
service_host: string;
};
/** LLMV2Calibration */
LLMV2Calibration: {
capable: components["schemas"]["LLMV2ProbabilityCalibration"];
@ -65038,6 +65075,37 @@ export interface operations {
};
};
};
get_kubernetes_pods_model__model_id__kubernetes_pods_get: {
parameters: {
query?: never;
header?: never;
path: {
model_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KubernetesPodsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
patch_model_model__model_id__update_patch: {
parameters: {
query?: never;

477
uv.lock generated
View file

@ -85,7 +85,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.3"
version = "3.14.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@ -98,126 +98,127 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" },
{ url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" },
{ url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" },
{ url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" },
{ url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" },
{ url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" },
{ url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" },
{ url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" },
{ url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" },
{ url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" },
{ url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" },
{ url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" },
{ url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
{ url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
{ url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
{ url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
{ url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
{ url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
{ url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
{ url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
{ url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
{ url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
{ url = "https://files.pythonhosted.org/packages/ca/82/f7f0652e3a845e095d8505a2807a0fe5782abb71f99b2f516c93ff0be492/aiohttp-3.14.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef692a24087a699c0a4a26af45e746e0c1eae2116f6d8a5ff91d8aae2b867b45", size = 834351, upload-time = "2026-10-11T00:59:07.668Z" },
{ url = "https://files.pythonhosted.org/packages/95/c5/10cb0a5195bc3ec05a74ee494f8431a15e559800f2e869f3526ad4a16f04/aiohttp-3.14.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1220353657ad49493551f089ce02f1a348fd57ffd585bfec77f2f3c4fe3a7346", size = 560663, upload-time = "2026-10-11T00:59:09.853Z" },
{ url = "https://files.pythonhosted.org/packages/32/dc/b7fefdd7dd64d080da2d69d609344191cc94b0e2eba70200c371014c9b36/aiohttp-3.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:330900acd0dc4cb8b27f9c127fbaad770964845338493e7906ae3822e82dbf8d", size = 553263, upload-time = "2026-10-11T00:59:11.5Z" },
{ url = "https://files.pythonhosted.org/packages/61/5f/3271cad1b34347e8bf27abee20d501ebdc56cec97e8ae59cd3fabb23d917/aiohttp-3.14.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0684952aeae1f5dbfe02d46039338513b94009baecd15d8e4098a357c4c4a2a6", size = 1843813, upload-time = "2026-10-11T00:59:13.143Z" },
{ url = "https://files.pythonhosted.org/packages/67/af/2011b30fe61889af112ad380e6fd44a93cca4b51296f612cac666c177882/aiohttp-3.14.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d94e44be379e569758fee8a9a58431cfc3c2598c708b92b1cfe96c66b4c94aef", size = 1811243, upload-time = "2026-10-11T00:59:15.091Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ba/72f3ac523ed48d38cdfb169f1b2a2e93ce06d9fcf4f46d0f5692e9539bcc/aiohttp-3.14.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5af42135fdfebdadbc2bcd9c0842a48ccf0d62794c36a260b21dc4b94d1e0119", size = 1898815, upload-time = "2026-10-11T00:59:17.224Z" },
{ url = "https://files.pythonhosted.org/packages/e6/69/bbf24f3b555ea1befa7db1e2510c728c8b0e80e61ce360beb7d94bc562b7/aiohttp-3.14.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f07fe3ac408d8b3f768be471dc3f56d43843c47d97c66120534467a15ead197", size = 2022390, upload-time = "2026-10-11T00:59:19.244Z" },
{ url = "https://files.pythonhosted.org/packages/5d/38/01f5fc732d5373d6c099db0343279cd4e6607287d336bab6ff6ae9085a0d/aiohttp-3.14.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f375db73a39f5cf83696d500e21a67f418dc9a988955756f254be8f03b7b3651", size = 1856329, upload-time = "2026-10-11T00:59:21.19Z" },
{ url = "https://files.pythonhosted.org/packages/15/85/1f86fbd8fe34ad447c63e679404a1d5be435fe256a9d281b791fc1ed0d35/aiohttp-3.14.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8df7d481654ac96fe1ba9a02a9f67770fdd367823e0d5ef01b922725c4bd2cfa", size = 1712763, upload-time = "2026-10-11T00:59:22.982Z" },
{ url = "https://files.pythonhosted.org/packages/39/1e/ced51cf47e427e7aa6142c3bf6f33022472703bc9061289329532d3c40c0/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e95c8def4b81c5d68d5cf1f54c07acd7c0d2577af244e5b6da802120825737c6", size = 1812125, upload-time = "2026-10-11T00:59:25.032Z" },
{ url = "https://files.pythonhosted.org/packages/6a/f1/9610b4e263b554ca4191501ba72b952efe06556b647da74887abc5d41049/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2ebb54b3f932210503072f09974b4fb574d823e497a944adfdcd140a6a00255", size = 1829550, upload-time = "2026-10-11T00:59:27.078Z" },
{ url = "https://files.pythonhosted.org/packages/ac/8a/1d1fb27cf9fa99b13133de33044c4d10d7705be0d4173d6d56abad1b67f9/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:038c2c7e8caa26b6c8423779b5eaf1893904048a512c19b32fe841ffa5592b50", size = 1868179, upload-time = "2026-10-11T00:59:29.315Z" },
{ url = "https://files.pythonhosted.org/packages/24/9f/3cfafa86ff025623dcca0d30335db7b8a6f3fa348148441208147e827e04/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b7806e804889231b0e06469fd4a5c06313d1c0a3377322b6d9237fa5e0fe4167", size = 1697721, upload-time = "2026-10-11T00:59:31.161Z" },
{ url = "https://files.pythonhosted.org/packages/62/1d/86439a23bf32296e715d8c75535f0cd701821d36719324baef65bcd6af9e/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9bab2045550c4fe0f7baf89574db1b455c195750702ba96fef1f16972b146617", size = 1887000, upload-time = "2026-10-11T00:59:33.329Z" },
{ url = "https://files.pythonhosted.org/packages/1d/0f/6044f2d02b3874966d84cd119f9b8eda46aa2991230078aea4dd17750cc9/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96a2e584f0b9ed8f1fa33211397dcf67bb7069866402cb405d191c2f0defb9a3", size = 1842020, upload-time = "2026-10-11T00:59:35.384Z" },
{ url = "https://files.pythonhosted.org/packages/18/64/b49d2a34428ff937631b07dde74d5879a7a2c8559aab39331d40769e8cb6/aiohttp-3.14.5-cp310-cp310-win32.whl", hash = "sha256:602c1e9b718a3275c580149f947e7fac65044c0a20e599553fb12e9700da9eca", size = 489282, upload-time = "2026-10-11T00:59:37.061Z" },
{ url = "https://files.pythonhosted.org/packages/a6/7c/1114e723f1c4a65597820b605a0ddcd7f3b28fdfde3ac8d520d517ca582c/aiohttp-3.14.5-cp310-cp310-win_amd64.whl", hash = "sha256:bea559ad70218d230663e4210875735076a9bfea5994cef34a55a25faeaf2544", size = 512021, upload-time = "2026-10-11T00:59:38.7Z" },
{ url = "https://files.pythonhosted.org/packages/48/97/e08c646f4bad63d77876b24591d013d7f33f02fda7c931eafe3224f38467/aiohttp-3.14.5-cp310-cp310-win_arm64.whl", hash = "sha256:dca3fa8d8a0a26679862eccb0b1a9151b2b9f1cd2c212e7a6335778faaff5833", size = 493016, upload-time = "2026-10-11T00:59:40.525Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" },
{ url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" },
{ url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" },
{ url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" },
{ url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" },
{ url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" },
{ url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" },
{ url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" },
{ url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" },
{ url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" },
{ url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" },
{ url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" },
{ url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" },
{ url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" },
{ url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" },
{ url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" },
{ url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" },
{ url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" },
{ url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" },
{ url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" },
{ url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" },
{ url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" },
{ url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" },
{ url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" },
{ url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" },
{ url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" },
{ url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" },
{ url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" },
{ url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" },
{ url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" },
{ url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" },
{ url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" },
{ url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" },
{ url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" },
{ url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" },
{ url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" },
{ url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" },
{ url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" },
{ url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" },
{ url = "https://files.pythonhosted.org/packages/43/be/3184a1d34a8be665569eadb7e9e764b4629e4f3413e241cb2e4d6fecf3b3/aiohttp-3.14.5-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5", size = 540673, upload-time = "2026-10-11T01:01:41.354Z" },
{ url = "https://files.pythonhosted.org/packages/60/2a/d35f3ba4cf157b072e3b674bf9983047ca5ea5173c995d32d877e1191d36/aiohttp-3.14.5-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343", size = 554087, upload-time = "2026-10-11T01:01:43.719Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d2/61a33880ca4eaca95a9c60ca3f6beed15555af1028652dfaac601627787b/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738", size = 522613, upload-time = "2026-10-11T01:01:46.154Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/de579b299d2225dc2c6fd99d579d91f16c02a913fb5af9a3cf2fe9bd88ba/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c", size = 529682, upload-time = "2026-10-11T01:01:48.477Z" },
{ url = "https://files.pythonhosted.org/packages/f4/4a/ddb923564e15e053b6e060b0036e1694dcadcb13aa476c5a87dcad20e336/aiohttp-3.14.5-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3", size = 540120, upload-time = "2026-10-11T01:01:50.474Z" },
{ url = "https://files.pythonhosted.org/packages/3d/36/a640fbecaa53727a5900b892bdbe17b5f3e8cc88903e22864fe41b654def/aiohttp-3.14.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9", size = 818795, upload-time = "2026-10-11T01:01:52.665Z" },
{ url = "https://files.pythonhosted.org/packages/ef/b6/d52ca608859e271b5fa7944074802dc45f60e52c318a4ddc34edbf73586e/aiohttp-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db", size = 549667, upload-time = "2026-10-11T01:01:54.65Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b5/05b8ac39a76ff4bca89f42a4c2471560c71f894ff6e4bc16158c951874e3/aiohttp-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba", size = 548922, upload-time = "2026-10-11T01:01:56.547Z" },
{ url = "https://files.pythonhosted.org/packages/19/b0/5aa186d56ce2334dabe29b70bd99dc8ae926ee44184c0de64a53d415a4f3/aiohttp-3.14.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382", size = 1882422, upload-time = "2026-10-11T01:01:59.258Z" },
{ url = "https://files.pythonhosted.org/packages/db/f7/7d5c91bb9620db300c8ddb05337a9014301acb626223faff3abcb8ea47d7/aiohttp-3.14.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4", size = 1843621, upload-time = "2026-10-11T01:02:01.417Z" },
{ url = "https://files.pythonhosted.org/packages/30/0a/b208953b96d8f24b75f6da704f508e6c5cf3022f52b60c61933df082e89c/aiohttp-3.14.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1", size = 1927915, upload-time = "2026-10-11T01:02:03.697Z" },
{ url = "https://files.pythonhosted.org/packages/f6/79/90ebcccb55e2d1e11a1fed581d83bb966e38fb35fb4b2577fdc980f8707a/aiohttp-3.14.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677", size = 2036339, upload-time = "2026-10-11T01:02:06.046Z" },
{ url = "https://files.pythonhosted.org/packages/a6/66/55a8904b3a129fafdf94f9cc0a2e4ca09a3c914650be52355db7ad0bbdb6/aiohttp-3.14.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c", size = 1888041, upload-time = "2026-10-11T01:02:08.384Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b8/96b25da7329a52e42c812b1e8b076386039ec4fc312afa043d173d8147fc/aiohttp-3.14.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622", size = 1703849, upload-time = "2026-10-11T01:02:10.903Z" },
{ url = "https://files.pythonhosted.org/packages/1b/43/fbf976e3ae4c038d6f5c84945ab2150298c2d71201674d4e53d158063e75/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3", size = 1849703, upload-time = "2026-10-11T01:02:13.15Z" },
{ url = "https://files.pythonhosted.org/packages/8b/7d/218e912f4c1d89bde7ac551409be57ad2f6121638e56942d395a7cb1fa58/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0", size = 1852695, upload-time = "2026-10-11T01:02:15.295Z" },
{ url = "https://files.pythonhosted.org/packages/5a/42/252a1b9287e3b6e393a1c3bd1776f36af30f5f25f071bbf2b0cb7eba9116/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480", size = 1893493, upload-time = "2026-10-11T01:02:17.93Z" },
{ url = "https://files.pythonhosted.org/packages/78/97/71cae83d5100556fad1521684f7cd1e3e578432644f850245ed3bd969310/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f", size = 1693468, upload-time = "2026-10-11T01:02:20.666Z" },
{ url = "https://files.pythonhosted.org/packages/1f/69/73d88e97a8b5f0ca7a946d0011c0de99fb188b1687c7948ecd0553dc5bf0/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115", size = 1915398, upload-time = "2026-10-11T01:02:23.011Z" },
{ url = "https://files.pythonhosted.org/packages/05/f0/881644bcb15d4b258daea9b720a0af9dc4330496cc8d6ade9090cdd0cffc/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a", size = 1871470, upload-time = "2026-10-11T01:02:25.278Z" },
{ url = "https://files.pythonhosted.org/packages/7f/de/19d9ebbcce5aedaa3242d8a99ff8816a60bdb7629fb0084bf4a45bfd1f62/aiohttp-3.14.5-cp314-cp314-win32.whl", hash = "sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2", size = 485517, upload-time = "2026-10-11T01:02:27.579Z" },
{ url = "https://files.pythonhosted.org/packages/a9/74/8cdaf0e58c2588371670d5a9a8215bbb971d36940b6e5d051967dd05c07d/aiohttp-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67", size = 511755, upload-time = "2026-10-11T01:02:29.685Z" },
{ url = "https://files.pythonhosted.org/packages/1a/6b/e0100e25502430a531c7cf1482a378d0b65bf728ab60c01ee270e56bc469/aiohttp-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7", size = 495151, upload-time = "2026-10-11T01:02:32.163Z" },
{ url = "https://files.pythonhosted.org/packages/9d/c2/ca2ead7b655688c53c03aeeb6e96e6851c9ff08d6be7b13802f53a6ae8fd/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed", size = 855396, upload-time = "2026-10-11T01:02:34.443Z" },
{ url = "https://files.pythonhosted.org/packages/a7/70/22206fea409255a240c926ce11de48de354ae2bb90ca44f709c05497585d/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781", size = 567734, upload-time = "2026-10-11T01:02:36.644Z" },
{ url = "https://files.pythonhosted.org/packages/ce/e5/79a36c118308b56f8667d67e05d2fb6dc638ab45985704cdb199631bedaa/aiohttp-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550", size = 567379, upload-time = "2026-10-11T01:02:38.757Z" },
{ url = "https://files.pythonhosted.org/packages/63/a3/2ebec7dece3b1f02c30d2e484647f6f7b13952b1bb40a4cb285b849e8432/aiohttp-3.14.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d", size = 2050686, upload-time = "2026-10-11T01:02:41.169Z" },
{ url = "https://files.pythonhosted.org/packages/3a/d2/7e4d093db2f4450482652e7ef19a9e19919028f5135aa52bc4078c3beb80/aiohttp-3.14.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863", size = 1915077, upload-time = "2026-10-11T01:02:43.563Z" },
{ url = "https://files.pythonhosted.org/packages/a6/88/bd40d09958442a0a1df67da81de496361d6e2afc04f9db2950d835da750b/aiohttp-3.14.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99", size = 2027204, upload-time = "2026-10-11T01:02:46.185Z" },
{ url = "https://files.pythonhosted.org/packages/63/eb/3a601c1f8d3103c1a60ea981f20924855da9f575d2007fc38f8898b792fb/aiohttp-3.14.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5", size = 2149520, upload-time = "2026-10-11T01:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/22/d0/4e41bfe1b1ce1cb6f6d2e59fa7a88ef5cf92c402b2d07e9018778ba9edbc/aiohttp-3.14.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f", size = 2002039, upload-time = "2026-10-11T01:02:51.293Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/72067019545c502b881b031153c437752ecef48d7d213bc0218ccebb4bfb/aiohttp-3.14.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0", size = 1786164, upload-time = "2026-10-11T01:02:53.533Z" },
{ url = "https://files.pythonhosted.org/packages/d9/fe/7741efd6119bfb7a00827fe6f7b84b4409de58d888ae21adc5a9a6824992/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d", size = 1970689, upload-time = "2026-10-11T01:02:55.888Z" },
{ url = "https://files.pythonhosted.org/packages/43/e7/342a13bf67f34d269bf2f7e870ecd72b99c832cc6f0a271a9210c0ebfb84/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658", size = 1951967, upload-time = "2026-10-11T01:02:58.467Z" },
{ url = "https://files.pythonhosted.org/packages/e7/d4/fdb3b27340617e5e64df18a70fa89097778652ea5c7c7e2f79def76999c3/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84", size = 1968511, upload-time = "2026-10-11T01:03:00.883Z" },
{ url = "https://files.pythonhosted.org/packages/83/b2/e8f88298de78d1a951f36f9f966d38ec6ed1d4721303ba02064a545e8aa6/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441", size = 1778497, upload-time = "2026-10-11T01:03:03.206Z" },
{ url = "https://files.pythonhosted.org/packages/22/68/9ccdb93d664345c546be7f34480b921774d8c0d98f47e70d7e03b115d475/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210", size = 1999676, upload-time = "2026-10-11T01:03:05.829Z" },
{ url = "https://files.pythonhosted.org/packages/57/4a/a33cfa6dcb00e94194ae4fe710432ca4ed111e016356b4ba4d2f4c3a124c/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d", size = 1963437, upload-time = "2026-10-11T01:03:08.196Z" },
{ url = "https://files.pythonhosted.org/packages/f4/20/eacbecfea3b5c3dcbfc9b023e3a5460f43e5dda16d06a2877ebe7184c3f3/aiohttp-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab", size = 503973, upload-time = "2026-10-11T01:03:10.481Z" },
{ url = "https://files.pythonhosted.org/packages/ff/78/18eec294f6c8c5dc845dcf6d730a0147d8d0f17e86138a7bdb85e43a30fa/aiohttp-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3", size = 531190, upload-time = "2026-10-11T01:03:12.716Z" },
{ url = "https://files.pythonhosted.org/packages/9d/39/e53f8169acc85271ebd12b5b32ad7f1541b35639ccbe0f49034c64785d10/aiohttp-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e", size = 510360, upload-time = "2026-10-11T01:03:15.08Z" },
{ url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" },
]
[[package]]
@ -4507,6 +4508,7 @@ version = "1.107.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "anyio" },
{ name = "async-timeout", marker = "python_full_version < '3.11'" },
{ name = "boto3" },
{ name = "click" },
@ -4760,6 +4762,7 @@ requires-dist = [
{ name = "a2a-sdk", marker = "extra == 'extra-proxy'", specifier = ">=1.1.0,<2.0" },
{ name = "aiohttp", specifier = ">=3.14.2,<4.0" },
{ name = "anthropic", extras = ["vertex"], marker = "extra == 'proxy-runtime'", specifier = ">=0.84.0,<1.0" },
{ name = "anyio", specifier = ">=4.14.0,<5.0" },
{ name = "apscheduler", marker = "extra == 'proxy'", specifier = ">=3.11.2,<4.0" },
{ name = "async-timeout", marker = "python_full_version < '3.11'", specifier = ">=4.0.3,<6.0" },
{ name = "audioread", marker = "extra == 'stt-nvidia-riva'", specifier = ">=3.0.1" },
@ -10792,142 +10795,118 @@ wheels = [
[[package]]
name = "yarl"
version = "1.23.0"
version = "1.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764, upload-time = "2026-03-01T22:04:09.7Z" },
{ url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282, upload-time = "2026-03-01T22:04:11.892Z" },
{ url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053, upload-time = "2026-03-01T22:04:13.292Z" },
{ url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395, upload-time = "2026-03-01T22:04:15.133Z" },
{ url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143, upload-time = "2026-03-01T22:04:16.829Z" },
{ url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643, upload-time = "2026-03-01T22:04:18.519Z" },
{ url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700, upload-time = "2026-03-01T22:04:20.373Z" },
{ url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769, upload-time = "2026-03-01T22:04:23.055Z" },
{ url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114, upload-time = "2026-03-01T22:04:25.031Z" },
{ url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883, upload-time = "2026-03-01T22:04:27.281Z" },
{ url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172, upload-time = "2026-03-01T22:04:28.554Z" },
{ url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010, upload-time = "2026-03-01T22:04:30.385Z" },
{ url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285, upload-time = "2026-03-01T22:04:31.752Z" },
{ url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230, upload-time = "2026-03-01T22:04:33.844Z" },
{ url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008, upload-time = "2026-03-01T22:04:35.856Z" },
{ url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073, upload-time = "2026-03-01T22:04:38.268Z" },
{ url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328, upload-time = "2026-03-01T22:04:39.558Z" },
{ url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463, upload-time = "2026-03-01T22:04:41.454Z" },
{ url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" },
{ url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" },
{ url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" },
{ url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" },
{ url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" },
{ url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" },
{ url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" },
{ url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" },
{ url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" },
{ url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" },
{ url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" },
{ url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" },
{ url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" },
{ url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" },
{ url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" },
{ url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" },
{ url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" },
{ url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" },
{ url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" },
{ url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" },
{ url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" },
{ url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" },
{ url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" },
{ url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" },
{ url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" },
{ url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" },
{ url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },
{ url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },
{ url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },
{ url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },
{ url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },
{ url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },
{ url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },
{ url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },
{ url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },
{ url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },
{ url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },
{ url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },
{ url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },
{ url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },
{ url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },
{ url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },
{ url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },
{ url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },
{ url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },
{ url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },
{ url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },
{ url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },
{ url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },
{ url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },
{ url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },
{ url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },
{ url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },
{ url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },
{ url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },
{ url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },
{ url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
{ url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
{ url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
{ url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
{ url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
{ url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
{ url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
{ url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
{ url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
{ url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
{ url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
{ url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
{ url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
{ url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
{ url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
{ url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
{ url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
{ url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
{ url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
{ url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
{ url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
{ url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
{ url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
{ url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
{ url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
{ url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
{ url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
{ url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
{ url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
{ url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
{ url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
{ url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
{ url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
{ url = "https://files.pythonhosted.org/packages/85/92/55fa9ee84cb8ec9930a910b5753936a926f918d8cc8965bdac571479c095/yarl-1.25.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:142c06c4d6a35ee3ec5da08499805e879cb3ca7c1fbfbecb0140fe72403818d6", size = 144695, upload-time = "2026-09-15T19:29:53.114Z" },
{ url = "https://files.pythonhosted.org/packages/16/b4/9edc8e605b16b2eb5bd0c2ccc73e2b15aab583862460767fb43f91f11839/yarl-1.25.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24ce942011a61953e7d313438038f4d32ff21387b775f58a957f7a07dd55ef95", size = 104332, upload-time = "2026-09-15T19:29:55.443Z" },
{ url = "https://files.pythonhosted.org/packages/ae/de/4204e6646e278b1cd4a9cf73ebe8b7cfcf16693f1249b324eb273d67602a/yarl-1.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e23c82b63cd7652fc24d33ed6cc17099d607aa3b4fc4ddc75e95062f3d82df4", size = 104449, upload-time = "2026-09-15T19:29:57.239Z" },
{ url = "https://files.pythonhosted.org/packages/1d/fc/21d74789198ad68a23a209ab0d73fe5a52d39ebbd9945041e72827acdd77/yarl-1.25.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee202350cf57abf0e9502a41601841019c25d3db7ff52d980aaf31446254059", size = 116828, upload-time = "2026-09-15T19:29:59.031Z" },
{ url = "https://files.pythonhosted.org/packages/8f/31/90891ddb61848c067ebd1fb505ab5902a6e7d4707c2f3823f67ff37883d3/yarl-1.25.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5df89f769cc8ff94c3d7e7603386fba309d25ce5240132d26c15baa8d0e96c4c", size = 107150, upload-time = "2026-09-15T19:30:00.868Z" },
{ url = "https://files.pythonhosted.org/packages/a1/15/f3d18743d3688b5f01aa83034b6c00665692ab20e1d0c89a29000d4979ea/yarl-1.25.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83e9f4a25085bd4b7214701a0794ff1f50fc633ffb8bdfebf07abdd81c2db126", size = 124704, upload-time = "2026-09-15T19:30:03.128Z" },
{ url = "https://files.pythonhosted.org/packages/99/37/718789d8004775d6a2db86b2afc72b1d156e0590b88a1b8634ddd4bdb8ee/yarl-1.25.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e636b64d24fd9c38053c5e389a1174c66361fa49dcfd220f4dd35b4abde7cb89", size = 129246, upload-time = "2026-09-15T19:30:05.014Z" },
{ url = "https://files.pythonhosted.org/packages/28/b9/7818ac6dec7fbcd16be2d19495807c01e2c38834e57d41c63356a75e786b/yarl-1.25.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5637ca8d0bd7fb72648a6c7934af4baaccb697657f7438c9d264fc2abb8b0b1", size = 118071, upload-time = "2026-09-15T19:30:06.977Z" },
{ url = "https://files.pythonhosted.org/packages/b2/e3/8d098cafb30a64df7283b5a5dd0d32d22a71b18a1b9ec071d0776ffe5c2a/yarl-1.25.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:683e362b8ba453080f7489c66f4ea794e751c35b72e7eab3575ef784c2fbc7fb", size = 116180, upload-time = "2026-09-15T19:30:08.836Z" },
{ url = "https://files.pythonhosted.org/packages/20/9c/fbe9432478d87e00eef80242e15179639df162cb3d5d8d978fc56dcb6c51/yarl-1.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df23df54b5114a17c2d0ef192433e2e5a9f0c5178c32375e90b7cfc965f349d0", size = 116578, upload-time = "2026-09-15T19:30:10.782Z" },
{ url = "https://files.pythonhosted.org/packages/b1/8e/b3dfd03732236b86b0bcf06a72a37de412ff6f82e719a947c3c651d4dece/yarl-1.25.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f53dcd26694f148f738edc052b5a69234833e739f10f4c3287bdfd8ec0f7b326", size = 108886, upload-time = "2026-09-15T19:30:12.951Z" },
{ url = "https://files.pythonhosted.org/packages/83/e1/94dacb650d4963d5f844bc3df7ba70288cb7b68ed5a4d070da71acb19b14/yarl-1.25.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b8075fe90bc08e40b8b8a1874fab42ee4c7b56af05c5886e9cc841397f916908", size = 124098, upload-time = "2026-09-15T19:30:14.963Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3f/c93f76a218258c7bfc60dd27fd56e9102bdbc625517f4a6ee673295fe4e4/yarl-1.25.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a8c2b841478068440d8b733005d13a5ef535b9928cbc05f17182d410f32ba449", size = 115768, upload-time = "2026-09-15T19:30:16.736Z" },
{ url = "https://files.pythonhosted.org/packages/a8/79/4d93f13c3b05cda3c962805dec28cbc255c50239b3457808abc5633a00c2/yarl-1.25.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca32926d7d77bcc8838425c4c95e040a3ace1cb7dfdae599013458dcda2607ca", size = 122461, upload-time = "2026-09-15T19:30:18.901Z" },
{ url = "https://files.pythonhosted.org/packages/c5/8e/e2ba83b3a9bfc1d3b882ad35fa8abc04a7af4d9356f2e329a23d0c8d889d/yarl-1.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:192a866877a49993949ef1975864ad8728bea28ee810f6abe1a0729c2b500426", size = 118295, upload-time = "2026-09-15T19:30:21.07Z" },
{ url = "https://files.pythonhosted.org/packages/42/67/cb5ea1baa0c0ac60bda44ce59f601133154af2a2e67722d3517f8793855d/yarl-1.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3f4d48a6112712973e676bd792121fee470e432d749177162d9949d5c9460a1b", size = 102929, upload-time = "2026-09-15T19:30:23.122Z" },
{ url = "https://files.pythonhosted.org/packages/2f/85/9a1e98de10fc0e738d517efd2a9984c3e7db6cf35fa72b2b373988a9e9b7/yarl-1.25.1-cp310-cp310-win_arm64.whl", hash = "sha256:48796ea00a303961507dc6c8437c4b325a6fc3f95f7c36c71b91ea9a8150963c", size = 98854, upload-time = "2026-09-15T19:30:25.102Z" },
{ url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" },
{ url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" },
{ url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" },
{ url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" },
{ url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" },
{ url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" },
{ url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" },
{ url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" },
{ url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" },
{ url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" },
{ url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" },
{ url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" },
{ url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" },
{ url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" },
{ url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" },
{ url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" },
{ url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" },
{ url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" },
{ url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" },
{ url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" },
{ url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" },
{ url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" },
{ url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" },
{ url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" },
{ url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" },
{ url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" },
{ url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" },
{ url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" },
{ url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" },
{ url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" },
{ url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" },
{ url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" },
{ url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" },
{ url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" },
{ url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" },
{ url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" },
{ url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" },
{ url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" },
{ url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" },
{ url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" },
{ url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" },
{ url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" },
{ url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" },
{ url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" },
{ url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" },
{ url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" },
{ url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" },
{ url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" },
{ url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" },
{ url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" },
{ url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" },
{ url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" },
{ url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" },
{ url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" },
{ url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" },
{ url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" },
{ url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" },
{ url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" },
{ url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" },
{ url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" },
{ url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" },
{ url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" },
{ url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" },
{ url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" },
{ url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" },
{ url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" },
{ url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" },
]
[[package]]