mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-28 06:23:07 +00:00
Merge pull request #47737 from BerriAI/litellm_backport_p0_batch_translation_cost_stable_1_105_x
fix(batches)!: backport P0 batch-cost, pricing and translation fixes to stable/1.105.x for v1.105.3
This commit is contained in:
commit
fc795d5bfd
77 changed files with 24756 additions and 23888 deletions
|
|
@ -122,8 +122,9 @@ OBJECT_KEYS: dict[str, JsonSchema] = {
|
|||
"properties": {
|
||||
"model": NON_BLANK_STRING,
|
||||
"region": NON_BLANK_STRING,
|
||||
"litellm_provider": {"type": "string", "enum": ["bedrock", "bedrock_mantle"]},
|
||||
},
|
||||
"description": "Model that counts tokens for this one, and the AWS region its count API runs in.",
|
||||
"description": "Model that counts tokens for this one, its counting provider, and the AWS region its count API runs in.",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
Polls LiteLLM_ManagedObjectTable to check if the batch job is complete, and if the cost has been tracked.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import replace as dataclasses_replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
|
|
@ -27,7 +29,12 @@ if TYPE_CHECKING:
|
|||
from litellm.types.utils import LiteLLMBatch
|
||||
|
||||
|
||||
def _error_names_resource(error: Exception, resource_id: str) -> bool:
|
||||
return resource_id in re.findall(r"""[^\s"'`]+""", str(error))
|
||||
|
||||
|
||||
CHECK_BATCH_COST_USER_AGENT = "LiteLLM Proxy/CheckBatchCost"
|
||||
RECENT_BATCH_POLL_WINDOW: Final = timedelta(hours=48)
|
||||
|
||||
PROVIDER_TERMINAL_BATCH_STATUSES: Final[Tuple[str, ...]] = (
|
||||
"completed",
|
||||
|
|
@ -53,6 +60,15 @@ class _ManagedObjectRow(Protocol):
|
|||
@property
|
||||
def created_by(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def created_at(self) -> datetime: ...
|
||||
|
||||
@property
|
||||
def updated_at(self) -> datetime: ...
|
||||
|
||||
@property
|
||||
def status(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def file_object(self) -> object: ...
|
||||
|
||||
|
|
@ -98,6 +114,11 @@ class CheckBatchCost:
|
|||
# the guaranteed-failing primary query on every subsequent cycle.
|
||||
self._has_batch_processed_column: bool = True
|
||||
self.batch_processed_support_confirmed: bool = False
|
||||
self._poll_cursor: tuple[datetime, str] | None = None
|
||||
self._poll_before: datetime = datetime.now(timezone.utc)
|
||||
self._arrival_cursor: tuple[datetime, str] | None = None
|
||||
self._arrival_before: datetime = self._poll_before
|
||||
self._poll_arrivals: bool = False
|
||||
|
||||
@staticmethod
|
||||
def _is_missing_batch_processed_column_error(err: Exception) -> bool:
|
||||
|
|
@ -269,29 +290,9 @@ class CheckBatchCost:
|
|||
f"(older than {MANAGED_OBJECT_STALENESS_CUTOFF_DAYS} days) as stale_expired"
|
||||
)
|
||||
|
||||
if not self._has_batch_processed_column:
|
||||
return
|
||||
|
||||
# A row already in a terminal status is never rewritten by the sweep above, so
|
||||
# without this it keeps a poll-page slot forever and starves newer batches.
|
||||
retired: Final = await _managed_object_table(self.prisma_client).update_many(
|
||||
where={
|
||||
"file_purpose": "batch",
|
||||
"batch_processed": False,
|
||||
"status": {"in": ["complete", "completed"]},
|
||||
"created_at": {"lt": cutoff},
|
||||
},
|
||||
data={"batch_processed": True},
|
||||
)
|
||||
if retired > 0:
|
||||
verbose_proxy_logger.warning(
|
||||
f"CheckBatchCost: gave up on {retired} completed managed objects older than "
|
||||
f"{MANAGED_OBJECT_STALENESS_CUTOFF_DAYS} days that were never costed"
|
||||
)
|
||||
|
||||
async def _fallback_find_jobs(self) -> "Sequence[_ManagedObjectRow]":
|
||||
"""Query batch jobs without the batch_processed filter (for older schemas)."""
|
||||
return await _managed_object_table(self.prisma_client).find_many(
|
||||
return await self._find_jobs(
|
||||
where={
|
||||
"file_purpose": "batch",
|
||||
"status": {
|
||||
|
|
@ -305,10 +306,59 @@ class CheckBatchCost:
|
|||
]
|
||||
},
|
||||
},
|
||||
take=MAX_OBJECTS_PER_POLL_CYCLE,
|
||||
order={"created_at": "asc"},
|
||||
)
|
||||
|
||||
async def _find_jobs(self, where: Mapping[str, object]) -> Sequence[_ManagedObjectRow]:
|
||||
arrivals: Final = self._poll_arrivals
|
||||
page: Final = await self._find_job_page(where, arrivals=arrivals)
|
||||
self._poll_arrivals = not arrivals if page else arrivals
|
||||
if page:
|
||||
return page
|
||||
return await self._find_job_page(where, arrivals=not arrivals)
|
||||
|
||||
async def _find_job_page(self, where: Mapping[str, object], *, arrivals: bool) -> Sequence[_ManagedObjectRow]:
|
||||
cursor: Final = self._arrival_cursor if arrivals else self._poll_cursor
|
||||
if cursor is None:
|
||||
if arrivals:
|
||||
self._arrival_before = datetime.now(timezone.utc)
|
||||
else:
|
||||
self._poll_before = datetime.now(timezone.utc) - RECENT_BATCH_POLL_WINDOW
|
||||
before: Final = self._arrival_before if arrivals else self._poll_before
|
||||
page: Final = await _managed_object_table(self.prisma_client).find_many(
|
||||
where={
|
||||
**where,
|
||||
"created_at": {
|
||||
"lte": before,
|
||||
**({"gt": before - RECENT_BATCH_POLL_WINDOW} if arrivals else {}),
|
||||
},
|
||||
**(
|
||||
{
|
||||
"OR": [
|
||||
{"created_at": {"gt": cursor[0]}},
|
||||
{"created_at": cursor[0], "id": {"gt": cursor[1]}},
|
||||
]
|
||||
}
|
||||
if cursor is not None
|
||||
else {}
|
||||
),
|
||||
},
|
||||
take=MAX_OBJECTS_PER_POLL_CYCLE,
|
||||
order=[{"created_at": "asc"}, {"id": "asc"}],
|
||||
)
|
||||
next_cursor: Final = (page[-1].created_at, page[-1].id) if len(page) == MAX_OBJECTS_PER_POLL_CYCLE else None
|
||||
if arrivals:
|
||||
self._arrival_cursor = next_cursor
|
||||
else:
|
||||
self._poll_cursor = next_cursor
|
||||
return page
|
||||
|
||||
def _unprocessed_snapshot(self, job: _ManagedObjectRow) -> Mapping[str, object]:
|
||||
return {
|
||||
"id": job.id,
|
||||
"updated_at": job.updated_at,
|
||||
**({"batch_processed": False} if self._has_batch_processed_column else {}),
|
||||
}
|
||||
|
||||
async def _retire_job(self, job: "_ManagedObjectRow", reason: str) -> None:
|
||||
"""
|
||||
Take a row that can never be costed out of the poll page. Leaving it selectable
|
||||
|
|
@ -322,8 +372,8 @@ class CheckBatchCost:
|
|||
else {"status": "stale_expired"}
|
||||
)
|
||||
try:
|
||||
await _managed_object_table(self.prisma_client).update(
|
||||
where={"id": job.id},
|
||||
retired: Final = await _managed_object_table(self.prisma_client).update_many(
|
||||
where=self._unprocessed_snapshot(job),
|
||||
data=data,
|
||||
)
|
||||
except Exception as db_err:
|
||||
|
|
@ -331,12 +381,14 @@ class CheckBatchCost:
|
|||
f"CheckBatchCost: failed to retire uncostable job {job.id} ({reason}): {db_err}"
|
||||
)
|
||||
return
|
||||
if retired == 0:
|
||||
return
|
||||
verbose_proxy_logger.warning(
|
||||
f"CheckBatchCost: job {job.id} can never be costed ({reason}), "
|
||||
"so it will no longer be polled"
|
||||
)
|
||||
|
||||
async def _claim_job_for_costing(self, job: "_ManagedObjectRow") -> bool:
|
||||
async def _claim_job_for_costing(self, job: "_ManagedObjectRow", response: "LiteLLMBatch") -> bool:
|
||||
"""
|
||||
Atomically flip batch_processed from false to true, returning whether this pod won
|
||||
the row. Every pod and uvicorn worker schedules its own poller against the shared
|
||||
|
|
@ -350,10 +402,16 @@ class CheckBatchCost:
|
|||
"""
|
||||
if not self._has_batch_processed_column:
|
||||
return True
|
||||
from litellm_enterprise.proxy.hooks.managed_files import postgres_storable_json
|
||||
|
||||
try:
|
||||
claimed: Final = await _managed_object_table(self.prisma_client).update_many(
|
||||
where={"id": job.id, "batch_processed": False},
|
||||
data={"batch_processed": True},
|
||||
data={
|
||||
"batch_processed": True,
|
||||
"status": response.status if response.status != "completed" else "complete",
|
||||
"file_object": postgres_storable_json(response),
|
||||
},
|
||||
)
|
||||
except Exception as db_err:
|
||||
verbose_proxy_logger.error(
|
||||
|
|
@ -362,7 +420,9 @@ class CheckBatchCost:
|
|||
return False
|
||||
return claimed > 0
|
||||
|
||||
async def _release_job_claim(self, job: "_ManagedObjectRow") -> None:
|
||||
async def _release_job_claim(
|
||||
self, job: "_ManagedObjectRow", original_status: str | None, original_file_object: object
|
||||
) -> None:
|
||||
"""Give a claimed row back once billing it failed, so a later poll cycle retries it.
|
||||
|
||||
Safe to match on batch_processed=True: while this poller is active the retrieve
|
||||
|
|
@ -374,7 +434,11 @@ class CheckBatchCost:
|
|||
try:
|
||||
await _managed_object_table(self.prisma_client).update_many(
|
||||
where={"id": job.id, "batch_processed": True},
|
||||
data={"batch_processed": False},
|
||||
data={
|
||||
"batch_processed": False,
|
||||
"status": original_status,
|
||||
"file_object": json.dumps(original_file_object),
|
||||
},
|
||||
)
|
||||
except Exception as db_err:
|
||||
verbose_proxy_logger.error(
|
||||
|
|
@ -408,13 +472,13 @@ class CheckBatchCost:
|
|||
|
||||
from litellm.exceptions import NotFoundError
|
||||
|
||||
return isinstance(error, (NotFoundError, openai.NotFoundError)) and batch_id in str(error)
|
||||
return isinstance(error, (NotFoundError, openai.NotFoundError)) and _error_names_resource(error, batch_id)
|
||||
|
||||
def _batch_deployment_exists(self, model_id: str) -> bool:
|
||||
"""A 404 only proves the batch is gone when it came from the batch's own
|
||||
deployment. Once that deployment leaves the router, default fallbacks can
|
||||
silently send the retrieve to a provider that never saw the batch, so its
|
||||
404 must not retire the row; the staleness sweep bounds it instead."""
|
||||
404 must not retire a potentially recoverable row."""
|
||||
return self.llm_router.get_deployment(model_id=model_id) is not None
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -429,7 +493,7 @@ class CheckBatchCost:
|
|||
|
||||
if not output_file_id:
|
||||
return False
|
||||
return isinstance(error, (NotFoundError, openai.NotFoundError)) and output_file_id in str(error)
|
||||
return isinstance(error, (NotFoundError, openai.NotFoundError)) and _error_names_resource(error, output_file_id)
|
||||
|
||||
async def _finalize_unbilled_terminal_job(
|
||||
self, job: "_ManagedObjectRow", response: "LiteLLMBatch"
|
||||
|
|
@ -456,10 +520,12 @@ class CheckBatchCost:
|
|||
"file_object": response.model_dump_json(),
|
||||
**({"batch_processed": True} if self._has_batch_processed_column else {}),
|
||||
}
|
||||
await _managed_object_table(self.prisma_client).update(
|
||||
where={"id": job.id},
|
||||
finalized: Final = await _managed_object_table(self.prisma_client).update_many(
|
||||
where=self._unprocessed_snapshot(job),
|
||||
data=update_data,
|
||||
)
|
||||
if finalized == 0:
|
||||
return
|
||||
verbose_proxy_logger.info(
|
||||
f"CheckBatchCost: marked job {job.id} as {response.status} in DB"
|
||||
)
|
||||
|
|
@ -671,8 +737,6 @@ class CheckBatchCost:
|
|||
|
||||
@staticmethod
|
||||
def _get_input_file_id(job: "_ManagedObjectRow") -> Optional[str]:
|
||||
import json
|
||||
|
||||
from litellm.types.utils import LiteLLMBatch
|
||||
|
||||
file_object = job.file_object
|
||||
|
|
@ -833,8 +897,7 @@ class CheckBatchCost:
|
|||
)
|
||||
except Exception as _e:
|
||||
verbose_proxy_logger.warning(
|
||||
f"CheckBatchCost: failed to create managed file ID for "
|
||||
f"{_file_attr}={_raw_file_id!r}: {_e}"
|
||||
f"CheckBatchCost: failed to create managed file ID for {_file_attr}={_raw_file_id!r}: {_e}"
|
||||
)
|
||||
|
||||
# Pass the deployment's router-registered pricing (litellm_params custom
|
||||
|
|
@ -876,8 +939,9 @@ class CheckBatchCost:
|
|||
failed_requests=output_file_result.failed_requests + error_file_failed_requests,
|
||||
)
|
||||
)
|
||||
logging_model: Final = batch_result.models[0] if batch_result.models else litellm_model_name or model_id
|
||||
logging_obj = LiteLLMLogging(
|
||||
model=batch_result.models[0],
|
||||
model=logging_model,
|
||||
messages=[{"role": "user", "content": "<retrieve_batch>"}],
|
||||
stream=False,
|
||||
call_type="aretrieve_batch",
|
||||
|
|
@ -908,27 +972,35 @@ class CheckBatchCost:
|
|||
custom_llm_provider=str(llm_provider) if llm_provider else None,
|
||||
)
|
||||
|
||||
if not await self._claim_job_for_costing(job):
|
||||
original_status: Final = job.status
|
||||
original_file_object: Final = job.file_object
|
||||
if not await self._claim_job_for_costing(job, response.model_copy(update={"usage": batch_result.usage})):
|
||||
verbose_proxy_logger.info(
|
||||
f"CheckBatchCost: batch {batch_id} (job {job.id}) was claimed by another pod "
|
||||
"in this window, so its cost is already being tracked there"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
await logging_obj.async_success_handler(
|
||||
result=response,
|
||||
batch_cost=batch_result.cost,
|
||||
batch_usage=batch_result.usage,
|
||||
batch_models=batch_result.models,
|
||||
batch_successful_requests=batch_result.successful_requests,
|
||||
batch_failed_requests=batch_result.failed_requests,
|
||||
batch_prompt_cost=batch_result.prompt_cost,
|
||||
batch_completion_cost=batch_result.completion_cost,
|
||||
)
|
||||
except Exception:
|
||||
await self._release_job_claim(job)
|
||||
raise
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import track_batch_accounting
|
||||
|
||||
with track_batch_accounting(logging_obj.litellm_call_id) as accounting:
|
||||
try:
|
||||
await logging_obj.async_success_handler(
|
||||
result=response,
|
||||
batch_cost=batch_result.cost,
|
||||
batch_usage=batch_result.usage,
|
||||
batch_models=batch_result.models,
|
||||
batch_successful_requests=batch_result.successful_requests,
|
||||
batch_failed_requests=batch_result.failed_requests,
|
||||
batch_prompt_cost=batch_result.prompt_cost,
|
||||
batch_completion_cost=batch_result.completion_cost,
|
||||
)
|
||||
finally:
|
||||
if accounting.retryable_failure:
|
||||
await self._release_job_claim(job, original_status, original_file_object)
|
||||
|
||||
if accounting.retryable_failure:
|
||||
return None
|
||||
|
||||
# Record batch duration (completed_at - created_at)
|
||||
if prom_logger and response.completed_at and response.created_at:
|
||||
|
|
@ -976,7 +1048,7 @@ class CheckBatchCost:
|
|||
# endpoint may transition a batch to "complete" before
|
||||
# CheckBatchCost runs. The batch_processed=False filter
|
||||
# already prevents reprocessing finished batches.
|
||||
jobs = await _managed_object_table(self.prisma_client).find_many(
|
||||
jobs = await self._find_jobs(
|
||||
where={
|
||||
"file_purpose": "batch",
|
||||
"batch_processed": False,
|
||||
|
|
@ -989,8 +1061,6 @@ class CheckBatchCost:
|
|||
]
|
||||
},
|
||||
},
|
||||
take=MAX_OBJECTS_PER_POLL_CYCLE,
|
||||
order={"created_at": "asc"},
|
||||
)
|
||||
self.batch_processed_support_confirmed = True
|
||||
except Exception as query_err:
|
||||
|
|
@ -1036,10 +1106,7 @@ class CheckBatchCost:
|
|||
continue
|
||||
|
||||
## RETRIEVE THE BATCH JOB OUTPUT FILE
|
||||
if (
|
||||
response.status in PROVIDER_TERMINAL_BATCH_STATUSES
|
||||
and response.output_file_id is not None
|
||||
):
|
||||
if response.status in PROVIDER_TERMINAL_BATCH_STATUSES and response.output_file_id is not None:
|
||||
try:
|
||||
tracked = await self._track_completed_batch_cost(
|
||||
job=job,
|
||||
|
|
@ -1060,7 +1127,7 @@ class CheckBatchCost:
|
|||
continue
|
||||
verbose_proxy_logger.error(
|
||||
f"CheckBatchCost: failed to track cost for batch {batch_id} "
|
||||
f"(job {job.id}); leaving it unprocessed so the next poll retries: {tracking_err}"
|
||||
f"(job {job.id}): {tracking_err}"
|
||||
)
|
||||
self._record_error(prom_logger, "cost_tracking_error")
|
||||
continue
|
||||
|
|
@ -1070,22 +1137,20 @@ class CheckBatchCost:
|
|||
# Track this job for the final metrics summary
|
||||
processed_models.append(tracked)
|
||||
|
||||
# mark the job as complete
|
||||
try:
|
||||
update_data: dict = {
|
||||
"status": response.status if response.status != "completed" else "complete",
|
||||
"file_object": response.model_dump_json(),
|
||||
}
|
||||
if self._has_batch_processed_column:
|
||||
update_data["batch_processed"] = True
|
||||
await _managed_object_table(self.prisma_client).update(
|
||||
where={"id": job.id},
|
||||
data=update_data,
|
||||
)
|
||||
except Exception as db_err:
|
||||
verbose_proxy_logger.error(
|
||||
f"CheckBatchCost: failed to mark job {job.id} complete in DB: {db_err}"
|
||||
)
|
||||
if not self._has_batch_processed_column:
|
||||
try:
|
||||
await _managed_object_table(self.prisma_client).update(
|
||||
where={"id": job.id},
|
||||
data={
|
||||
"status": response.status if response.status != "completed" else "complete",
|
||||
"file_object": response.model_dump_json(),
|
||||
},
|
||||
)
|
||||
except Exception as db_err:
|
||||
verbose_proxy_logger.error(
|
||||
f"CheckBatchCost: failed to mark job {job.id} complete in DB: {db_err}"
|
||||
)
|
||||
continue
|
||||
|
||||
elif response.status in PROVIDER_TERMINAL_BATCH_STATUSES:
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from uuid import NAMESPACE_URL, uuid5
|
|||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm import Router, verbose_logger
|
||||
|
|
@ -205,6 +205,31 @@ def _managed_object_table(prisma_client: PrismaClient) -> _ManagedObjectTableAct
|
|||
return prisma_client.db.litellm_managedobjecttable
|
||||
|
||||
|
||||
_JSON_VALUE_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
||||
_POSTGRES_UNSTORABLE_CHARACTER: Final = "\x00"
|
||||
|
||||
|
||||
def _storable_text(text: str) -> str:
|
||||
return text.replace(_POSTGRES_UNSTORABLE_CHARACTER, "")
|
||||
|
||||
|
||||
def _storable_json_value(value: JsonValue) -> JsonValue:
|
||||
if isinstance(value, str):
|
||||
return _storable_text(value)
|
||||
if isinstance(value, list):
|
||||
return [_storable_json_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {_storable_text(key): _storable_json_value(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def postgres_storable_json(file_object: BaseModel) -> str:
|
||||
dumped: Final = file_object.model_dump_json()
|
||||
if "\\u0000" not in dumped:
|
||||
return dumped
|
||||
return _JSON_VALUE_ADAPTER.dump_json(_storable_json_value(_JSON_VALUE_ADAPTER.validate_json(dumped))).decode()
|
||||
|
||||
|
||||
def _storage_metadata_of(file_object: OpenAIFileObject | None) -> Mapping[str, str]:
|
||||
hidden_params: Final = cast( # cast-ok: _hidden_params is an untyped attribute the upload path sets
|
||||
"Mapping[str, object]", getattr(file_object, "_hidden_params", None) or {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.73"
|
||||
version = "0.1.73.post1"
|
||||
description = "Package for LiteLLM Enterprise features"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.1.73"
|
||||
version = "0.1.73.post1"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
|
|
@ -137,12 +137,20 @@ pub(crate) fn transform_messages_request(
|
|||
messages: strip_encrypted_reasoning_blocks(messages),
|
||||
params: MessagesOptionalParams {
|
||||
context_management,
|
||||
extra: without_null_safeguards(request.params.extra),
|
||||
..request.params
|
||||
},
|
||||
..request
|
||||
})
|
||||
}
|
||||
|
||||
fn without_null_safeguards(extra: Map<String, Value>) -> Map<String, Value> {
|
||||
extra
|
||||
.into_iter()
|
||||
.filter(|(key, value)| key != "safeguards" || !value.is_null())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn update_headers_with_anthropic_beta(
|
||||
headers: Headers,
|
||||
request: &MessagesRequest,
|
||||
|
|
@ -150,6 +158,13 @@ pub(crate) fn update_headers_with_anthropic_beta(
|
|||
merge_beta_headers(headers, feature_betas(request))
|
||||
}
|
||||
|
||||
fn carries_safeguards(params: &MessagesOptionalParams) -> bool {
|
||||
params
|
||||
.extra
|
||||
.get("safeguards")
|
||||
.is_some_and(|value| !value.is_null())
|
||||
}
|
||||
|
||||
fn feature_betas(request: &MessagesRequest) -> BetaSet {
|
||||
let params = &request.params;
|
||||
let tools = params.tools.as_deref();
|
||||
|
|
@ -163,6 +178,7 @@ fn feature_betas(request: &MessagesRequest) -> BetaSet {
|
|||
.then_some(AnthropicBeta::PerTurnControl20260701),
|
||||
has_advisor_tool(tools).then_some(AnthropicBeta::AdvisorTool20260301),
|
||||
is_tool_search_used(tools).then_some(AnthropicBeta::AdvancedToolUse20251120),
|
||||
carries_safeguards(params).then_some(AnthropicBeta::DangerousToolUse20260903),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
|
|
@ -1010,6 +1026,8 @@ mod tests {
|
|||
json!({"messages": [{"role": "user", "content": "hi", "output_config": null}]}),
|
||||
&["per-turn-control-2026-07-01"]
|
||||
)]
|
||||
#[case::safeguards(json!({"safeguards": [{"type": "dangerous_tool_use"}]}), &["dangerous-tool-use-2026-09-03"])]
|
||||
#[case::null_safeguards(json!({"safeguards": null}), &[])]
|
||||
fn feature_betas_follow_the_request(#[case] fields: Value, #[case] expected: &[&str]) {
|
||||
assert_eq!(feature_betas(&request(fields)), betas(expected));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,10 +29,16 @@ pub struct ModelInfo {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_32k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_100k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_100k_tokens_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_128k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_1hr: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_1hr_above_100k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_1hr_above_200k_tokens: Option<f64>,
|
||||
|
|
@ -82,6 +88,10 @@ pub struct ModelInfo {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_32k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_100k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_100k_tokens_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_128k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -200,6 +210,10 @@ pub struct ModelInfo {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_32k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_100k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_100k_tokens_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_128k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -355,6 +369,10 @@ pub struct ModelInfo {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_32k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_100k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_100k_tokens_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_128k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
|
|||
|
|
@ -74,3 +74,20 @@ fn checked_in_catalog_and_backup_match() {
|
|||
"invalid registry aliases"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::input("input_cost_per_token_above_100k_tokens")]
|
||||
#[case::input_batches("input_cost_per_token_above_100k_tokens_batches")]
|
||||
#[case::output("output_cost_per_token_above_100k_tokens")]
|
||||
#[case::output_batches("output_cost_per_token_above_100k_tokens_batches")]
|
||||
#[case::cache_creation("cache_creation_input_token_cost_above_100k_tokens")]
|
||||
#[case::cache_creation_batches("cache_creation_input_token_cost_above_100k_tokens_batches")]
|
||||
#[case::cache_creation_1hr("cache_creation_input_token_cost_above_1hr_above_100k_tokens")]
|
||||
#[case::cache_read("cache_read_input_token_cost_above_100k_tokens")]
|
||||
#[case::cache_read_batches("cache_read_input_token_cost_above_100k_tokens_batches")]
|
||||
fn registry_validation_keeps_above_100k_tier_rates(#[case] field: &str) {
|
||||
let mut entry = Map::new();
|
||||
entry.insert("litellm_provider".into(), "anthropic".into());
|
||||
entry.insert(field.into(), 5e-7.into());
|
||||
validate_model_entry("test", &Value::Object(entry)).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@
|
|||
"computer-use-2025-11-24": "computer-use-2025-11-24",
|
||||
"context-1m-2025-08-07": "context-1m-2025-08-07",
|
||||
"context-management-2025-06-27": "context-management-2025-06-27",
|
||||
"dangerous-tool-use-2026-09-03": null,
|
||||
"dangerous-tool-use-2026-09-03": "dangerous-tool-use-2026-09-03",
|
||||
"effort-2025-11-24": "effort-2025-11-24",
|
||||
"fast-mode-2026-02-01": "fast-mode-2026-02-01",
|
||||
"files-api-2025-04-14": "files-api-2025-04-14",
|
||||
|
|
|
|||
|
|
@ -22,17 +22,28 @@ Environment Variables:
|
|||
- LITELLM_ANTHROPIC_BETA_HEADERS_URL: Custom URL for remote config (optional)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import cache
|
||||
from importlib.resources import files
|
||||
from typing import Final
|
||||
from typing import Final, TypeAlias
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import verbose_logger
|
||||
|
||||
# Cache for the loaded configuration
|
||||
_BETA_HEADERS_CONFIG: dict | None = None
|
||||
_BETA_HEADERS_CONFIG_LOCK: Final = threading.Lock()
|
||||
_BETA_HEADERS_CONFIG_LOADER: Final = ThreadPoolExecutor(max_workers=1, thread_name_prefix="litellm-beta-headers-config")
|
||||
|
||||
BetaHeadersConfig: TypeAlias = Mapping[str, str | Mapping[str, str | None]]
|
||||
_BETA_HEADERS_CONFIG_ADAPTER: Final = TypeAdapter(BetaHeadersConfig)
|
||||
|
||||
|
||||
class GetAnthropicBetaHeadersConfig:
|
||||
|
|
@ -174,13 +185,27 @@ def _load_beta_headers_config() -> dict:
|
|||
if _BETA_HEADERS_CONFIG is not None:
|
||||
return _BETA_HEADERS_CONFIG
|
||||
|
||||
# Get the URL from environment or use default
|
||||
from litellm import anthropic_beta_headers_url
|
||||
with _BETA_HEADERS_CONFIG_LOCK:
|
||||
if _BETA_HEADERS_CONFIG is not None:
|
||||
return _BETA_HEADERS_CONFIG
|
||||
|
||||
_BETA_HEADERS_CONFIG = get_beta_headers_config(url=anthropic_beta_headers_url)
|
||||
verbose_logger.debug("Loaded and cached beta headers config")
|
||||
# Get the URL from environment or use default
|
||||
from litellm import anthropic_beta_headers_url
|
||||
|
||||
return _BETA_HEADERS_CONFIG
|
||||
_BETA_HEADERS_CONFIG = get_beta_headers_config(url=anthropic_beta_headers_url)
|
||||
verbose_logger.debug("Loaded and cached beta headers config")
|
||||
|
||||
return _BETA_HEADERS_CONFIG
|
||||
|
||||
|
||||
async def aload_beta_headers_config() -> None:
|
||||
if _BETA_HEADERS_CONFIG is None:
|
||||
await asyncio.get_running_loop().run_in_executor(_BETA_HEADERS_CONFIG_LOADER, _load_beta_headers_config)
|
||||
|
||||
|
||||
@cache
|
||||
def _bundled_beta_headers_config() -> BetaHeadersConfig:
|
||||
return _BETA_HEADERS_CONFIG_ADAPTER.validate_python(GetAnthropicBetaHeadersConfig.load_local_beta_headers_config())
|
||||
|
||||
|
||||
def reload_beta_headers_config() -> dict:
|
||||
|
|
@ -288,6 +313,14 @@ def is_beta_header_supported(
|
|||
return beta_header in provider_mapping and provider_mapping[beta_header] is not None
|
||||
|
||||
|
||||
def is_beta_header_supported_by_bundled_config(beta_header: str, provider: str) -> bool:
|
||||
config: Final = _bundled_beta_headers_config()
|
||||
aliases: Final = config.get("provider_aliases", {})
|
||||
canonical_provider: Final = (aliases.get(provider) if isinstance(aliases, Mapping) else None) or provider
|
||||
provider_mapping: Final = config.get(canonical_provider, {})
|
||||
return isinstance(provider_mapping, Mapping) and provider_mapping.get(beta_header) is not None
|
||||
|
||||
|
||||
def get_provider_beta_header(
|
||||
anthropic_beta_header: str,
|
||||
provider: str,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from collections.abc import Iterable, Iterator, Mapping
|
|||
from dataclasses import dataclass
|
||||
from dataclasses import replace as dataclasses_replace
|
||||
from enum import Enum
|
||||
from typing import Any, Final, Literal
|
||||
from typing import Any, Final, Literal, cast
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -772,9 +772,17 @@ def _batch_response_was_successful(
|
|||
message batch results lines report ``result.type == "succeeded"``; Bedrock
|
||||
batch output lines report ``modelOutput`` (and no ``error``).
|
||||
"""
|
||||
if custom_llm_provider == "anthropic":
|
||||
return _get_anthropic_result_from_batch_results_line(batch_job_output_file).get("type") == "succeeded"
|
||||
if custom_llm_provider == "bedrock":
|
||||
return batch_job_output_file.get("modelOutput") is not None and batch_job_output_file.get("error") is None
|
||||
_response: Final[dict] = batch_job_output_file.get("response", None) or {}
|
||||
return _response.get("status_code", None) == 200
|
||||
envelope_key: Final = "result" if custom_llm_provider == "anthropic" else "response"
|
||||
envelope: Final = batch_job_output_file.get(envelope_key)
|
||||
if not isinstance(envelope, Mapping):
|
||||
if envelope is not None:
|
||||
verbose_logger.warning("batch output %s must be an object, treating the line as unsuccessful", envelope_key)
|
||||
return False
|
||||
response: Final = cast(Mapping[object, object], envelope) # cast-ok: mapping shape validated above
|
||||
return (
|
||||
response.get("type") == "succeeded"
|
||||
if custom_llm_provider == "anthropic"
|
||||
else response.get("status_code") == 200
|
||||
)
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ from litellm.types.utils import (
|
|||
TranscriptionUsageTokensObject,
|
||||
Usage,
|
||||
VectorStoreSearchResponse,
|
||||
is_server_derived_pricing_key,
|
||||
)
|
||||
from litellm.utils import (
|
||||
CallTypes,
|
||||
|
|
@ -139,6 +140,8 @@ from litellm.utils import (
|
|||
TextCompletionResponse,
|
||||
TranscriptionResponse,
|
||||
_cached_get_model_info_helper,
|
||||
model_info_with_pricing_overrides,
|
||||
runtime_model_cost_registrations,
|
||||
token_counter,
|
||||
)
|
||||
|
||||
|
|
@ -412,6 +415,7 @@ def cost_per_token(
|
|||
### REQUEST MODEL ###
|
||||
request_model: str | None = None, # original request model for router detection
|
||||
custom_model_info: OCRPricing | None = None,
|
||||
bedrock_model_info: ModelInfo | None = None,
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Calculates the cost per token for a given model, prompt tokens, and completion tokens.
|
||||
|
|
@ -699,6 +703,15 @@ def cost_per_token(
|
|||
elif custom_llm_provider == "anthropic":
|
||||
return anthropic_cost_per_token(model=model, usage=usage_block, service_tier=service_tier)
|
||||
elif custom_llm_provider == "bedrock":
|
||||
resolved_bedrock_info: Final = bedrock_model_info or _bedrock_pricing_info(model, None, "bedrock", False)
|
||||
if resolved_bedrock_info is not None:
|
||||
return generic_cost_per_token(
|
||||
model=model,
|
||||
usage=usage_block,
|
||||
custom_llm_provider="bedrock",
|
||||
service_tier=service_tier,
|
||||
model_info=resolved_bedrock_info,
|
||||
)
|
||||
return bedrock_cost_per_token(model=model, usage=usage_block, service_tier=service_tier)
|
||||
elif custom_llm_provider == "openai":
|
||||
return openai_cost_per_token(
|
||||
|
|
@ -814,6 +827,59 @@ def _cost_map_entry_prices_anything(entry: Mapping[str, object]) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def _bedrock_registration_has_prices(entry: Mapping[str, object]) -> bool:
|
||||
return _cost_map_entry_prices_anything(entry) or any(
|
||||
value is not None and field.startswith("cache_") and is_server_derived_pricing_key(field)
|
||||
for field, value in entry.items()
|
||||
)
|
||||
|
||||
|
||||
def _bedrock_pricing_info(
|
||||
model: str | None,
|
||||
invoked_model_id: str | None,
|
||||
custom_llm_provider: str | None,
|
||||
explicit_pricing: bool,
|
||||
) -> ModelInfo | None:
|
||||
if explicit_pricing or custom_llm_provider != "bedrock" or not model:
|
||||
return None
|
||||
registration: Final = next(
|
||||
(
|
||||
(key, entry)
|
||||
for key, entry in runtime_model_cost_registrations(model, "bedrock")
|
||||
if _bedrock_registration_has_prices(entry)
|
||||
),
|
||||
None,
|
||||
)
|
||||
from litellm.llms.bedrock.common_utils import extract_model_name_from_bedrock_arn
|
||||
|
||||
profile_name: Final = (
|
||||
extract_model_name_from_bedrock_arn(invoked_model_id)
|
||||
if invoked_model_id and invoked_model_id.startswith("arn:")
|
||||
else invoked_model_id
|
||||
)
|
||||
profile: Final = _cost_map_model_info(profile_name, "bedrock") if profile_name else None
|
||||
if profile is None or not _declares_rates(profile):
|
||||
return _cost_map_model_info(registration[0], None) if registration is not None else None
|
||||
if registration is None or registration[0] == profile["key"]:
|
||||
return profile
|
||||
return model_info_with_pricing_overrides(profile, registration[1])
|
||||
|
||||
|
||||
def _declares_rates(model_info: ModelInfo | None) -> bool:
|
||||
entry: Final = _raw_cost_map_entry(model_info["key"]) if model_info is not None else None
|
||||
return entry is not None and _cost_map_entry_prices_anything(entry)
|
||||
|
||||
|
||||
def _invoked_model_id_for_cost(
|
||||
optional_params: object,
|
||||
logging_obj: "LitellmLoggingObject | None",
|
||||
) -> str | None:
|
||||
recorded: Final[object] = getattr(logging_obj, "model_call_details", None)
|
||||
return _get_hidden_str_for_cost_calc(optional_params, "model_id") or _get_hidden_str_for_cost_calc(
|
||||
recorded, "model_id"
|
||||
)
|
||||
|
||||
|
||||
def _select_model_name_for_cost_calc(
|
||||
model: str | None,
|
||||
completion_response: object | None,
|
||||
|
|
@ -822,6 +888,7 @@ def _select_model_name_for_cost_calc(
|
|||
custom_llm_provider: str | None = None,
|
||||
router_model_id: str | None = None,
|
||||
region_name: str | None = None,
|
||||
invoked_model_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
1. If custom pricing is true, return received model name
|
||||
|
|
@ -842,6 +909,13 @@ def _select_model_name_for_cost_calc(
|
|||
hidden_params: Final[dict | None] = getattr(completion_response, "_hidden_params", None)
|
||||
provider_response_model: Final = _get_hidden_str_for_cost_calc(hidden_params, "provider_response_model")
|
||||
explicit_pricing: Final = custom_pricing is True or base_model is not None
|
||||
configured_model: Final = model or (
|
||||
completion_response_model if isinstance(completion_response_model, str) else None
|
||||
)
|
||||
bedrock_model_info: Final = _bedrock_pricing_info(
|
||||
configured_model, invoked_model_id, custom_llm_provider, explicit_pricing
|
||||
)
|
||||
bedrock_pricing_model: Final = bedrock_model_info["key"] if bedrock_model_info is not None else None
|
||||
priced_from_response: Final = provider_response_model is not None or completion_response_model is not None
|
||||
priced_region: Final = (
|
||||
_get_hidden_str_for_cost_calc(hidden_params, "region_name") or region_name
|
||||
|
|
@ -859,8 +933,8 @@ def _select_model_name_for_cost_calc(
|
|||
else:
|
||||
return_model = model
|
||||
|
||||
elif base_model is not None or provider_response_model is not None:
|
||||
return_model = base_model if base_model is not None else provider_response_model
|
||||
elif base_model is not None or bedrock_pricing_model is not None or provider_response_model is not None:
|
||||
return_model = base_model if base_model is not None else bedrock_pricing_model or provider_response_model
|
||||
|
||||
elif completion_response_model is None and hidden_params is not None:
|
||||
if hidden_params.get("model", None) is not None and len(hidden_params["model"]) > 0:
|
||||
|
|
@ -1461,6 +1535,7 @@ def completion_cost(
|
|||
)
|
||||
|
||||
explicit_pricing: Final = custom_pricing is True or base_model is not None
|
||||
invoked_model_id: Final = _invoked_model_id_for_cost(optional_params, litellm_logging_obj)
|
||||
selected_model: Final = _select_model_name_for_cost_calc(
|
||||
model=model,
|
||||
completion_response=completion_response,
|
||||
|
|
@ -1469,6 +1544,13 @@ def completion_cost(
|
|||
base_model=base_model,
|
||||
router_model_id=router_model_id,
|
||||
region_name=region_name,
|
||||
invoked_model_id=invoked_model_id,
|
||||
)
|
||||
bedrock_model_info: Final = _bedrock_pricing_info(
|
||||
model or _get_response_model(completion_response),
|
||||
invoked_model_id,
|
||||
_get_provider_for_cost_calc(model, custom_llm_provider),
|
||||
explicit_pricing,
|
||||
)
|
||||
|
||||
potential_model_names: Final = [
|
||||
|
|
@ -1834,6 +1916,7 @@ def completion_cost(
|
|||
vertex_location=vertex_location,
|
||||
response=completion_response,
|
||||
custom_model_info=_ocr_model_info(litellm_logging_obj, custom_pricing, router_model_id),
|
||||
bedrock_model_info=bedrock_model_info,
|
||||
)
|
||||
|
||||
# Get additional costs from provider (e.g., routing fees, infrastructure costs)
|
||||
|
|
@ -1922,6 +2005,7 @@ def completion_cost(
|
|||
data_residency=data_residency,
|
||||
vertex_location=vertex_location,
|
||||
custom_cost_per_token=custom_cost_per_token,
|
||||
model_info=bedrock_model_info,
|
||||
)
|
||||
_reasoning_cost = _token_type_breakdown.reasoning_cost
|
||||
_cache_read_cost = _token_type_breakdown.cache_read_cost
|
||||
|
|
@ -2162,7 +2246,16 @@ def pricing_entry_for_cost_calc(
|
|||
custom_llm_provider=custom_llm_provider,
|
||||
router_model_id=router_model_id,
|
||||
region_name=region_name,
|
||||
invoked_model_id=_invoked_model_id_for_cost(None, litellm_logging_obj),
|
||||
)
|
||||
bedrock_model_info: Final = _bedrock_pricing_info(
|
||||
model or _get_response_model(completion_response),
|
||||
_invoked_model_id_for_cost(None, litellm_logging_obj),
|
||||
_get_provider_for_cost_calc(model, custom_llm_provider),
|
||||
custom_pricing is True or base_model is not None,
|
||||
)
|
||||
if bedrock_model_info is not None:
|
||||
return bedrock_model_info["key"], bedrock_model_info
|
||||
candidates: Final = (selected_model, _get_response_model(completion_response), model)
|
||||
resolved: Final = next(
|
||||
(info for info in (_cost_map_model_info(name, custom_llm_provider) for name in candidates if name) if info),
|
||||
|
|
|
|||
|
|
@ -5970,6 +5970,7 @@ class StandardLoggingPayloadSetup:
|
|||
custom_llm_provider: str | None,
|
||||
init_response_obj: object,
|
||||
api_base: str | None = None,
|
||||
router_model_id: str | None = None,
|
||||
) -> StandardLoggingModelInformation:
|
||||
model_cost_name: Final = _select_model_name_for_cost_calc(
|
||||
model=base_model if custom_pricing else None,
|
||||
|
|
@ -5977,12 +5978,23 @@ class StandardLoggingPayloadSetup:
|
|||
base_model=base_model,
|
||||
custom_pricing=custom_pricing,
|
||||
)
|
||||
pricing_model_name: Final = (
|
||||
_select_model_name_for_cost_calc(
|
||||
model=base_model if custom_pricing else None,
|
||||
completion_response=init_response_obj,
|
||||
base_model=base_model,
|
||||
custom_pricing=custom_pricing,
|
||||
router_model_id=router_model_id,
|
||||
)
|
||||
if router_model_id is not None
|
||||
else model_cost_name
|
||||
)
|
||||
if model_cost_name is None:
|
||||
model_cost_information = StandardLoggingModelInformation(model_map_key="", model_map_value=None)
|
||||
else:
|
||||
try:
|
||||
_model_cost_information: Final = litellm.get_model_info(
|
||||
model=model_cost_name,
|
||||
model=pricing_model_name or model_cost_name,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
api_base=api_base,
|
||||
)
|
||||
|
|
@ -6651,6 +6663,7 @@ def get_standard_logging_object_payload(
|
|||
custom_llm_provider=kwargs.get("custom_llm_provider"),
|
||||
init_response_obj=init_response_obj,
|
||||
api_base=litellm_params.get("api_base"),
|
||||
router_model_id=logging_obj.get_router_model_id(),
|
||||
)
|
||||
|
||||
error_information, error_str = StandardLoggingPayloadSetup.get_error_information_for_logging_payload(
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ def _parse_token_threshold(threshold: str) -> float:
|
|||
|
||||
|
||||
def _parse_above_token_threshold(key: str) -> float:
|
||||
return _parse_token_threshold(key.split("_above_")[1].split("_tokens")[0])
|
||||
return _parse_token_threshold(key.rsplit("_above_", 1)[1].split("_tokens")[0])
|
||||
|
||||
|
||||
def _prompt_exceeds_threshold(prompt_tokens: int, threshold: float, inclusive: bool) -> bool:
|
||||
|
|
@ -668,10 +668,12 @@ def _get_token_base_cost(
|
|||
# Optimization: collect threshold keys first to avoid sorting all model_info keys.
|
||||
# Standard thresholds and thresholds suffixed for this request's service tier both count.
|
||||
tier_key_suffix: Final = _get_service_tier_cost_key("", service_tier)
|
||||
threshold_prefixes: Final = tuple(f"{prefix}_above_" for prefix in _BATCH_RATE_PREFIXES)
|
||||
threshold_keys: Final = [
|
||||
k
|
||||
for k in model_info
|
||||
if k.startswith("input_cost_per_token_above_")
|
||||
if k.startswith(threshold_prefixes)
|
||||
and "_tokens" in k
|
||||
and (
|
||||
not k.endswith(_NON_STANDARD_THRESHOLD_SUFFIXES) or (tier_key_suffix != "" and k.endswith(tier_key_suffix))
|
||||
)
|
||||
|
|
@ -679,12 +681,12 @@ def _get_token_base_cost(
|
|||
|
||||
# Only sort the threshold keys (typically 1-2 keys instead of 66+)
|
||||
threshold: float | None = None
|
||||
for key in sorted(threshold_keys, key=_parse_above_token_threshold, reverse=True):
|
||||
for key in sorted(threshold_keys, key=_parse_above_token_threshold):
|
||||
value = model_info.get(key)
|
||||
if value is not None:
|
||||
try:
|
||||
# Handle both formats: _above_128k_tokens and _above_128_tokens
|
||||
threshold_str = key.split("_above_")[1].split("_tokens")[0]
|
||||
threshold_str = key.rsplit("_above_", 1)[1].split("_tokens")[0]
|
||||
threshold = _parse_above_token_threshold(key)
|
||||
if _prompt_exceeds_threshold(usage.prompt_tokens, threshold, threshold_is_inclusive):
|
||||
# Prefer a service_tier-specific above-threshold key when available,
|
||||
|
|
@ -697,7 +699,7 @@ def _get_token_base_cost(
|
|||
service_tier,
|
||||
)
|
||||
if service_tier
|
||||
else key
|
||||
else f"input_cost_per_token_above_{threshold_str}_tokens"
|
||||
)
|
||||
prompt_base_cost = cast(
|
||||
float,
|
||||
|
|
@ -753,8 +755,6 @@ def _get_token_base_cost(
|
|||
)
|
||||
|
||||
cache_read_cost = _get_cost_per_unit(model_info, cache_read_tiered_key, cache_read_cost)
|
||||
|
||||
break
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
except Exception:
|
||||
|
|
@ -1669,20 +1669,23 @@ def get_billed_token_rates(
|
|||
vertex_location: str | None = None,
|
||||
current_time: datetime | None = None,
|
||||
custom_cost_per_token: CostPerToken | None = None,
|
||||
model_info: ModelInfo | None = None,
|
||||
) -> BilledTokenRates | None:
|
||||
"""Rates the cost calculator bills ``usage`` at, resolved exactly as the totals and the token-type
|
||||
breakdown resolve them. None when the model's pricing cannot be resolved."""
|
||||
if custom_cost_per_token is not None:
|
||||
return _custom_pricing_rates(custom_cost_per_token)
|
||||
try:
|
||||
model_info: Final = apply_provider_cache_read_default(
|
||||
get_model_info(model=model, custom_llm_provider=custom_llm_provider),
|
||||
resolved_model_info: Final = apply_provider_cache_read_default(
|
||||
model_info
|
||||
if model_info is not None
|
||||
else get_model_info(model=model, custom_llm_provider=custom_llm_provider),
|
||||
custom_llm_provider,
|
||||
)
|
||||
except Exception: # noqa: BLE001 # get_model_info raises a bare Exception for an unmapped model: no rates
|
||||
return None
|
||||
return _cost_map_billed_rates(
|
||||
model_info=model_info,
|
||||
model_info=resolved_model_info,
|
||||
usage=usage,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
service_tier=service_tier,
|
||||
|
|
@ -1701,6 +1704,7 @@ def get_token_type_cost_breakdown(
|
|||
vertex_location: str | None = None,
|
||||
current_time: datetime | None = None,
|
||||
custom_cost_per_token: CostPerToken | None = None,
|
||||
model_info: ModelInfo | None = None,
|
||||
) -> TokenTypeCostBreakdown:
|
||||
"""
|
||||
Provider-agnostic cost of reasoning and cache tokens, derived from the usage
|
||||
|
|
@ -1725,6 +1729,7 @@ def get_token_type_cost_breakdown(
|
|||
vertex_location=vertex_location,
|
||||
current_time=current_time,
|
||||
custom_cost_per_token=custom_cost_per_token,
|
||||
model_info=model_info,
|
||||
)
|
||||
if rates is None:
|
||||
return TokenTypeCostBreakdown(0.0, 0.0, 0.0)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from typing import Any, Final
|
|||
|
||||
import httpx
|
||||
|
||||
from litellm.anthropic_beta_headers_manager import is_beta_header_supported_by_bundled_config
|
||||
from litellm.exceptions import AuthenticationError
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.litellm_logging import verbose_logger
|
||||
|
|
@ -618,6 +619,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
optional_params: dict,
|
||||
custom_llm_provider: str = "anthropic",
|
||||
messages: Sequence[object] = (),
|
||||
beta_map_provider: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Auto-inject anthropic-beta headers based on features used.
|
||||
|
|
@ -634,8 +636,9 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
optional_params: Optional parameters including tools, context_management, output_format, speed
|
||||
custom_llm_provider: Provider name for looking up correct tool search header
|
||||
messages: Request messages, scanned for per-message output_config
|
||||
beta_map_provider: Provider whose beta map column gates safeguards, defaults to custom_llm_provider
|
||||
"""
|
||||
beta_values: Final[set] = set()
|
||||
beta_values: Final[set[str]] = set()
|
||||
|
||||
existing_beta: Final = tuple(
|
||||
piece.strip()
|
||||
|
|
@ -685,6 +688,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
if optional_params.get("speed") == "fast":
|
||||
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.FAST_MODE_2026_02_01.value)
|
||||
|
||||
if optional_params.get("safeguards") is not None and is_beta_header_supported_by_bundled_config(
|
||||
ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value,
|
||||
beta_map_provider or custom_llm_provider,
|
||||
):
|
||||
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value)
|
||||
|
||||
if _messages_carry_output_config(messages):
|
||||
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,34 +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. "
|
||||
"Supported values are: 'low', 'medium', and 'high'. "
|
||||
"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,
|
||||
|
|
@ -110,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,
|
||||
|
|
|
|||
|
|
@ -30,10 +30,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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2660,13 +2660,13 @@ class AmazonConverseConfig(BaseConfig):
|
|||
|
||||
## HANDLE TOOL CALLS
|
||||
_message: Final = Message(**chat_completion_message)
|
||||
initial_finish_reason = map_finish_reason(completion_response["stopReason"])
|
||||
mapped_finish_reason: Final = map_finish_reason(completion_response["stopReason"])
|
||||
|
||||
# When json_mode filtered out all synthetic tool calls the response
|
||||
# is plain content, not a pending tool invocation. Fix finish_reason
|
||||
# so callers (e.g. OpenAI SDK) don't misinterpret it.
|
||||
if resolved_json_mode and not filtered_tools and tools:
|
||||
initial_finish_reason = "stop"
|
||||
initial_finish_reason: Final = (
|
||||
"stop"
|
||||
if resolved_json_mode and not filtered_tools and tools and mapped_finish_reason == "tool_calls"
|
||||
else mapped_finish_reason
|
||||
)
|
||||
|
||||
(
|
||||
returned_message,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import json
|
|||
import ssl
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Coroutine, Iterator, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import lru_cache, partial
|
||||
from functools import lru_cache, partial, reduce
|
||||
from types import MappingProxyType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
|
|
@ -32,7 +32,7 @@ import litellm.litellm_core_utils
|
|||
import litellm.types
|
||||
import litellm.types.utils
|
||||
from litellm._logging import _redact_string, verbose_logger
|
||||
from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta
|
||||
from litellm.anthropic_beta_headers_manager import aload_beta_headers_config, update_headers_with_filtered_beta
|
||||
from litellm.constants import MAX_FILE_LIST_LIMIT, REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
|
||||
from litellm.files.types import FileContentStreamingResult
|
||||
from litellm.litellm_core_utils.agentic_followup_kwargs import build_agentic_followup_kwargs
|
||||
|
|
@ -46,6 +46,7 @@ from litellm.litellm_core_utils.audio_utils.subtitle_utils import (
|
|||
synthesize_subtitle_document,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import set_provider_response_headers_in_hidden_params
|
||||
from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value
|
||||
from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS
|
||||
from litellm.litellm_core_utils.llm_request_utils import serialize_multipart_form_fields
|
||||
from litellm.litellm_core_utils.realtime_errors import (
|
||||
|
|
@ -2150,6 +2151,16 @@ class BaseLLMHTTPHandler:
|
|||
merged_headers.update(extra_headers_from_kwargs)
|
||||
if provider_specific_headers:
|
||||
merged_headers.update(provider_specific_headers)
|
||||
request_params: Final = reduce(
|
||||
delete_nested_value,
|
||||
litellm_params.get("additional_drop_params") or (),
|
||||
anthropic_messages_optional_request_params,
|
||||
)
|
||||
if (
|
||||
request_params.get("safeguards") is not None
|
||||
and anthropic_messages_provider_config.should_filter_anthropic_beta_headers()
|
||||
):
|
||||
await aload_beta_headers_config()
|
||||
(
|
||||
headers,
|
||||
api_base,
|
||||
|
|
@ -2157,7 +2168,7 @@ class BaseLLMHTTPHandler:
|
|||
headers=merged_headers or {},
|
||||
model=model,
|
||||
messages=messages,
|
||||
optional_params=anthropic_messages_optional_request_params,
|
||||
optional_params=request_params,
|
||||
litellm_params=dict(litellm_params),
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
|
|
@ -2188,20 +2199,11 @@ class BaseLLMHTTPHandler:
|
|||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
additional_drop_params: Final[list[str]] = litellm_params.get("additional_drop_params") or []
|
||||
if additional_drop_params:
|
||||
from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value
|
||||
|
||||
for path in additional_drop_params:
|
||||
anthropic_messages_optional_request_params = delete_nested_value(
|
||||
anthropic_messages_optional_request_params, path
|
||||
)
|
||||
|
||||
# Prepare request body
|
||||
request_body: Final = anthropic_messages_provider_config.transform_anthropic_messages_request(
|
||||
model=model,
|
||||
messages=messages,
|
||||
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
|
||||
anthropic_messages_optional_request_params=request_params,
|
||||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
|
@ -2307,7 +2309,7 @@ class BaseLLMHTTPHandler:
|
|||
|
||||
held_back_tool_names: Final = self._server_fulfilled_tools_in_request(
|
||||
logging_obj=logging_obj,
|
||||
tools=anthropic_messages_optional_request_params.get("tools"),
|
||||
tools=request_params.get("tools"),
|
||||
)
|
||||
initial_response = AgenticAnthropicStreamingIterator(
|
||||
completion_stream=completion_stream,
|
||||
|
|
@ -2315,7 +2317,7 @@ class BaseLLMHTTPHandler:
|
|||
model=model,
|
||||
messages=messages,
|
||||
anthropic_messages_provider_config=anthropic_messages_provider_config,
|
||||
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
|
||||
anthropic_messages_optional_request_params=request_params,
|
||||
logging_obj=logging_obj,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
kwargs=kwargs_for_agentic,
|
||||
|
|
@ -2338,7 +2340,7 @@ class BaseLLMHTTPHandler:
|
|||
model=model,
|
||||
messages=messages,
|
||||
anthropic_messages_provider_config=anthropic_messages_provider_config,
|
||||
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
|
||||
anthropic_messages_optional_request_params=request_params,
|
||||
logging_obj=logging_obj,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
kwargs=kwargs_for_agentic,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping, Sequenc
|
|||
from typing import TYPE_CHECKING, Any, Final, Literal, cast, overload
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
|
||||
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
|
||||
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
|
||||
|
|
@ -21,6 +21,9 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
strip_name_from_message,
|
||||
)
|
||||
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
|
||||
from litellm.llms.base_llm.base_utils import (
|
||||
type_to_response_format_param, # pyright: ignore[reportUnknownVariableType] # base_utils helper returns an untyped dict
|
||||
)
|
||||
from litellm.types.llms.anthropic import AllAnthropicToolsValues
|
||||
from litellm.types.llms.databricks import (
|
||||
AllDatabricksContentValues,
|
||||
|
|
@ -59,6 +62,10 @@ from ...anthropic.chat.transformation import (
|
|||
from ...openai_like.chat.transformation import OpenAILikeChatConfig
|
||||
from ..common_utils import DatabricksBase, DatabricksException
|
||||
|
||||
_RESPONSE_FORMAT_ADAPTER: ( # mutable-ok: mirrors the dict return contract of get_json_schema_from_pydantic_object
|
||||
Final[TypeAdapter[dict[str, object] | None]]
|
||||
) = TypeAdapter(dict[str, object] | None)
|
||||
|
||||
|
||||
def _is_bare_assistant_message(message_dict: Mapping[str, object]) -> bool:
|
||||
"""Databricks rejects assistant messages with neither content nor tool calls, e.g. a replayed
|
||||
|
|
@ -68,6 +75,15 @@ def _is_bare_assistant_message(message_dict: Mapping[str, object]) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def _collapse_single_text_block(message: Mapping[str, object]) -> Mapping[str, object]:
|
||||
content: Final = message.get("content")
|
||||
match content:
|
||||
case [{"type": "text", "text": str() as text}] if content == [{"type": "text", "text": text}]:
|
||||
return {**message, "content": text}
|
||||
case _:
|
||||
return message
|
||||
|
||||
|
||||
def _sanitize_empty_content(message_dict: dict[str, object]) -> None:
|
||||
"""
|
||||
Remove or filter content so empty text blocks are not sent.
|
||||
|
|
@ -191,6 +207,14 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
|
|||
def get_config(cls, *, model: str | None = None):
|
||||
return super().get_config()
|
||||
|
||||
def get_json_schema_from_pydantic_object(
|
||||
self,
|
||||
response_format: ( # mutable-ok: matches BaseConfig override signature
|
||||
type[BaseModel] | dict[str, object] | None
|
||||
),
|
||||
) -> dict[str, object] | None: # mutable-ok: BaseConfig contract returns a dict
|
||||
return _RESPONSE_FORMAT_ADAPTER.validate_python(type_to_response_format_param(response_format=response_format))
|
||||
|
||||
def get_required_params(self) -> list[ProviderField]:
|
||||
"""For a given provider, return it's required fields with a description"""
|
||||
return [
|
||||
|
|
@ -447,6 +471,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
|
|||
Databricks does not support:
|
||||
- 'name' in user message.
|
||||
- litellm's internal `thinking_blocks` / `reasoning_content` on assistant messages.
|
||||
- non-Claude json_object "json" checks read only string content, so a single text block is sent as a string.
|
||||
"""
|
||||
new_messages = []
|
||||
for idx, message in enumerate(messages):
|
||||
|
|
@ -462,7 +487,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
|
|||
_sanitize_empty_content(cast(dict[str, Any], _message))
|
||||
if _is_bare_assistant_message(_message):
|
||||
continue
|
||||
new_messages.append(_message)
|
||||
new_messages.append(_collapse_single_text_block(_message) if "claude" not in model else _message)
|
||||
|
||||
if "claude" not in model:
|
||||
new_messages = _split_parallel_tool_calls(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
@ -67,6 +67,10 @@ def _get_effort_level(value: str | dict | None) -> str | None:
|
|||
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
|
||||
|
|
@ -82,6 +86,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)
|
||||
|
|
@ -193,15 +200,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),
|
||||
|
|
@ -209,6 +211,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 [
|
||||
|
|
@ -269,12 +304,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"
|
||||
|
|
@ -286,31 +321,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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,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.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import *
|
||||
|
|
@ -136,6 +139,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
|
||||
|
|
@ -242,7 +259,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)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from litellm.llms.anthropic.pass_through.messages.transformation import (
|
|||
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
DEFAULT_ANTHROPIC_API_VERSION: Final = "2023-06-01"
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig):
|
|||
headers=normalized,
|
||||
optional_params=optional_params,
|
||||
messages=messages,
|
||||
beta_map_provider=LlmProviders.OPENAI_LIKE.value,
|
||||
)
|
||||
return merged, api_base
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ from litellm.litellm_core_utils.request_timeout_resolver import (
|
|||
get_configured_request_timeout,
|
||||
)
|
||||
from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer
|
||||
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,
|
||||
|
|
@ -1080,6 +1081,16 @@ def _resolve_openai_api_base(api_base: str | None) -> str:
|
|||
)
|
||||
|
||||
|
||||
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,
|
||||
|
|
@ -1129,7 +1140,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
|
||||
|
|
@ -1152,10 +1165,13 @@ def responses_api_bridge_check(
|
|||
)
|
||||
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
|
||||
|
|
@ -5744,6 +5760,7 @@ def completion(
|
|||
optional_params=processed_non_default_params, # [IMPORTANT] - using processed_non_default_params ensures consistent params logged to langfuse for finetuning / eval datasets.
|
||||
litellm_params=litellm_params,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
model_id=optional_params.get("model_id"),
|
||||
)
|
||||
if mock_response or mock_tool_calls or mock_timeout:
|
||||
kwargs.pop("mock_timeout", None) # remove for any fallbacks triggered
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1550,7 +1550,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
|
||||
def _unmask_responses_api_completed_chunk(self, chunk: object, pii_tokens: dict[str, str]) -> None:
|
||||
"""
|
||||
Unmask PII tokens in-place for a ``response.completed`` Responses API event.
|
||||
Unmask PII tokens in-place for a ``response.completed`` / ``response.incomplete`` Responses API event.
|
||||
|
||||
The chunk carries a ``response`` attribute (ResponsesAPIResponse) whose
|
||||
``output`` list holds message items. Each item has a ``content`` list of
|
||||
|
|
@ -1610,7 +1610,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
yield buffered_chunk
|
||||
remaining_chunks = []
|
||||
chunk_type = getattr(chunk, "type", None)
|
||||
if chunk_type == "response.completed" and pii_tokens:
|
||||
if chunk_type in ("response.completed", "response.incomplete") and pii_tokens:
|
||||
self._unmask_responses_api_completed_chunk(chunk, pii_tokens)
|
||||
saw_non_chat_chunk = True
|
||||
yield chunk
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ from dataclasses import dataclass
|
|||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from openai.types import Batch
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.caching import DualCache
|
||||
|
|
@ -16,7 +14,7 @@ from litellm.litellm_core_utils.duration_parser import duration_in_seconds
|
|||
from litellm.llms.bedrock.common_utils import get_bedrock_base_model
|
||||
from litellm.proxy._types import Litellm_EntityType, UserAPIKeyAuth
|
||||
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
|
||||
from litellm.router_utils.batch_utils import is_batch_retrieve_call_type
|
||||
from litellm.router_utils.batch_utils import batch_charged_once_marker_key, batch_id_to_charge_once
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import BudgetConfig, StandardLoggingPayload
|
||||
|
||||
|
|
@ -121,17 +119,6 @@ def model_budget_start_time_cache_key(
|
|||
return f"{_BUDGET_START_TIME_KEY_PREFIXES[entity_type]}:{entity_id}:{budget_model}:{budget_duration}"
|
||||
|
||||
|
||||
def batch_charged_once_marker_key(spend_key: str, batch_id: str) -> str:
|
||||
return f"{spend_key}:batch:{batch_id}"
|
||||
|
||||
|
||||
def batch_id_to_charge_once(call_type: object, response_obj: object, response_cost: float) -> str | None:
|
||||
"""A finished batch reports its whole cost on every poll, so its id is charged once per counter."""
|
||||
if response_cost <= 0 or not is_batch_retrieve_call_type(call_type):
|
||||
return None
|
||||
return response_obj.id if isinstance(response_obj, Batch) else None
|
||||
|
||||
|
||||
def resolve_model_budget(model: str, model_max_budget: Mapping[str, object]) -> ResolvedModelBudget | None:
|
||||
"""Find the `model_max_budget` entry that governs `model`, or None."""
|
||||
for candidate in _budget_model_candidates(model):
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import asyncio
|
||||
import traceback
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from collections.abc import Callable, Generator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol, cast
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import TypeIs
|
||||
|
||||
import litellm
|
||||
|
|
@ -66,6 +69,37 @@ from litellm.utils import get_end_user_id_for_cost_tracking
|
|||
if TYPE_CHECKING:
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
|
||||
class BatchAccountingOutcome:
|
||||
def __init__(self, call_id: str) -> None:
|
||||
self.call_id: Final = call_id
|
||||
self.write_started = False
|
||||
self.failed = False
|
||||
|
||||
@property
|
||||
def retryable_failure(self) -> bool:
|
||||
return self.failed and not self.write_started
|
||||
|
||||
|
||||
_BATCH_ACCOUNTING: Final[ContextVar[BatchAccountingOutcome | None]] = ContextVar("batch_accounting", default=None)
|
||||
_BATCH_CALLBACK_FIELDS: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
@contextmanager
|
||||
def track_batch_accounting(call_id: str) -> Generator[BatchAccountingOutcome, None, None]:
|
||||
outcome: Final = BatchAccountingOutcome(call_id)
|
||||
token: Final = _BATCH_ACCOUNTING.set(outcome)
|
||||
try:
|
||||
yield outcome
|
||||
finally:
|
||||
_BATCH_ACCOUNTING.reset(token)
|
||||
|
||||
|
||||
def _batch_accounting_for(call_id: object) -> BatchAccountingOutcome | None:
|
||||
outcome: Final = _BATCH_ACCOUNTING.get()
|
||||
return outcome if outcome is not None and call_id == outcome.call_id else None
|
||||
|
||||
|
||||
_UNATTRIBUTED_TRACKABLE_CALL_TYPES: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
CallTypes.pass_through.value,
|
||||
|
|
@ -404,6 +438,11 @@ class _ProxyDBLogger(CustomLogger):
|
|||
update_cache_read_keys,
|
||||
)
|
||||
|
||||
accounting: Final = (
|
||||
_batch_accounting_for(_BATCH_CALLBACK_FIELDS.validate_python(kwargs).get("litellm_call_id"))
|
||||
if _BATCH_ACCOUNTING.get() is not None
|
||||
else None
|
||||
)
|
||||
verbose_proxy_logger.debug("INSIDE _PROXY_track_cost_callback")
|
||||
try:
|
||||
verbose_proxy_logger.debug(
|
||||
|
|
@ -424,6 +463,8 @@ class _ProxyDBLogger(CustomLogger):
|
|||
)
|
||||
_write_spend_metadata_to_kwargs(kwargs=kwargs, metadata=metadata)
|
||||
budget_reservation: Final = _get_budget_reservation_from_metadata(metadata=metadata)
|
||||
if accounting is not None and budget_reservation is not None:
|
||||
accounting.write_started = True
|
||||
if (
|
||||
isinstance(completion_response, LiteLLMBatch)
|
||||
and kwargs.get("call_type") == CallTypes.aretrieve_batch.value
|
||||
|
|
@ -587,6 +628,8 @@ class _ProxyDBLogger(CustomLogger):
|
|||
f"Cost tracking failed for model={model}.\nDebug info - {cost_tracking_failure_debug_info}\nAdd custom pricing - https://docs.litellm.ai/docs/proxy/custom_pricing"
|
||||
)
|
||||
except Exception as e:
|
||||
if accounting is not None:
|
||||
accounting.failed = True
|
||||
failing_model: Final = kwargs.get("model", "")
|
||||
failing_call_type: Final = kwargs.get("call_type", "")
|
||||
error_msg: Final = (
|
||||
|
|
@ -908,7 +951,15 @@ async def _update_database_and_spend_counters_in_batch(
|
|||
from litellm.proxy.proxy_server import arm_update_cache_read
|
||||
|
||||
try:
|
||||
charged: Final = await proxy_logging_obj.db_spend_update_writer.update_database(
|
||||
writer: Final = proxy_logging_obj.db_spend_update_writer
|
||||
accounting: Final = (
|
||||
_batch_accounting_for(_BATCH_CALLBACK_FIELDS.validate_python(kwargs).get("litellm_call_id"))
|
||||
if _BATCH_ACCOUNTING.get() is not None
|
||||
else None
|
||||
)
|
||||
if accounting is not None:
|
||||
accounting.write_started = True
|
||||
charged: Final = await writer.update_database(
|
||||
token=user_api_key,
|
||||
response_cost=response_cost,
|
||||
user_id=user_id,
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ from litellm.types.llms.openai import (
|
|||
ReasoningSummaryTextDeltaEvent,
|
||||
ReasoningSummaryTextDoneEvent,
|
||||
ResponseCompletedEvent,
|
||||
ResponseIncompleteEvent,
|
||||
ResponseCreatedEvent,
|
||||
ResponseIncompleteEvent,
|
||||
ResponseInProgressEvent,
|
||||
ResponseInputParam,
|
||||
ResponsesAPIOptionalRequestParams,
|
||||
|
|
@ -918,7 +918,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
raise StopAsyncIteration
|
||||
|
||||
self.finished = self.is_stream_finished()
|
||||
response_completed_event: Final = self._emit_response_completed_event(self.litellm_model_response)
|
||||
response_completed_event: Final = self._emit_terminal_response_event(self.litellm_model_response)
|
||||
if response_completed_event:
|
||||
# Latch so wrappers (FallbackResponsesStreamWrapper) + proxy
|
||||
# container-ownership hook can read completed_response.
|
||||
|
|
@ -1301,7 +1301,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
stamp_responses_usage_cost(partial, self.litellm_logging_obj)
|
||||
return ResponseIncompleteEvent(type=ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE, response=partial)
|
||||
|
||||
def _emit_response_completed_event(self, litellm_model_response: ModelResponse) -> ResponseCompletedEvent | None:
|
||||
def _emit_terminal_response_event(
|
||||
self, litellm_model_response: ModelResponse
|
||||
) -> ResponseCompletedEvent | ResponseIncompleteEvent | None:
|
||||
if litellm_model_response:
|
||||
# Transform the response
|
||||
responses_api_response: Final = (
|
||||
|
|
@ -1321,6 +1323,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
# Encode the response ID to match non-streaming behavior
|
||||
encoded_response: Final = self._with_encoded_response_id(responses_api_response)
|
||||
|
||||
if responses_api_response.status == "incomplete":
|
||||
return ResponseIncompleteEvent(
|
||||
type=ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE,
|
||||
response=encoded_response,
|
||||
)
|
||||
return ResponseCompletedEvent(
|
||||
type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
|
||||
response=encoded_response,
|
||||
|
|
|
|||
|
|
@ -117,6 +117,44 @@ _INCOMPLETE_REASON_BY_FINISH_REASON: Final[Mapping[str, Literal["max_output_toke
|
|||
)
|
||||
|
||||
|
||||
class _EchoedResponsesRequestParams(TypedDict, total=False):
|
||||
"""Responses API request fields that the response object echoes back."""
|
||||
|
||||
instructions: ReadOnly[str]
|
||||
metadata: ReadOnly[dict[str, object]]
|
||||
parallel_tool_calls: ReadOnly[bool]
|
||||
temperature: ReadOnly[float]
|
||||
tools: ReadOnly[list[dict[str, object]]]
|
||||
top_p: ReadOnly[float]
|
||||
max_output_tokens: ReadOnly[int]
|
||||
previous_response_id: ReadOnly[str]
|
||||
reasoning: ReadOnly[dict[str, object]]
|
||||
text: ReadOnly[dict[str, object]]
|
||||
truncation: ReadOnly[Literal["auto", "disabled"]]
|
||||
user: ReadOnly[str]
|
||||
store: ReadOnly[bool]
|
||||
|
||||
|
||||
_ECHOED_PARAMS_ADAPTER: Final = TypeAdapter(_EchoedResponsesRequestParams)
|
||||
|
||||
|
||||
def _is_echoable(name: str, value: object) -> bool:
|
||||
try:
|
||||
_ = _ECHOED_PARAMS_ADAPTER.validate_python({name: value})
|
||||
except ValidationError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _echoable_request_params(request: Mapping[str, object]) -> _EchoedResponsesRequestParams:
|
||||
echoable: Final = {
|
||||
name: request[name]
|
||||
for name in _EchoedResponsesRequestParams.__optional_keys__
|
||||
if name in request and _is_echoable(name, request[name])
|
||||
}
|
||||
return _ECHOED_PARAMS_ADAPTER.validate_python(echoable)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResponsesToolChatForm:
|
||||
chat_tools: tuple[ChatToolParam, ...]
|
||||
|
|
@ -2280,7 +2318,7 @@ class LiteLLMCompletionResponsesConfig:
|
|||
The corresponding responses API status value (one of ResponsesAPIStatus)
|
||||
"""
|
||||
if finish_reason is None:
|
||||
return "completed"
|
||||
return "incomplete"
|
||||
|
||||
# Map finish reasons to status
|
||||
if finish_reason in ["stop", "tool_calls", "function_call"]:
|
||||
|
|
@ -2295,11 +2333,18 @@ class LiteLLMCompletionResponsesConfig:
|
|||
def _incomplete_details_for_finish_reason(
|
||||
finish_reason: str | None,
|
||||
existing: IncompleteDetails | None,
|
||||
completion_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> IncompleteDetails | None:
|
||||
if existing is not None:
|
||||
return existing
|
||||
if finish_reason is None:
|
||||
return None
|
||||
hit_cap: Final = (
|
||||
completion_tokens is not None
|
||||
and max_output_tokens is not None
|
||||
and completion_tokens >= max_output_tokens
|
||||
)
|
||||
return IncompleteDetails(reason="max_output_tokens") if hit_cap else None
|
||||
reason: Final = _INCOMPLETE_REASON_BY_FINISH_REASON.get(finish_reason)
|
||||
return IncompleteDetails(reason=reason) if reason is not None else None
|
||||
|
||||
|
|
@ -2419,9 +2464,13 @@ class LiteLLMCompletionResponsesConfig:
|
|||
if choices and len(choices) > 0:
|
||||
finish_reason = choices[0].finish_reason
|
||||
|
||||
echoed: Final = _echoable_request_params(responses_api_request)
|
||||
chat_usage: Final = getattr(chat_completion_response, "usage", None)
|
||||
incomplete_details: Final = LiteLLMCompletionResponsesConfig._incomplete_details_for_finish_reason(
|
||||
finish_reason=finish_reason,
|
||||
existing=getattr(chat_completion_response, "incomplete_details", None),
|
||||
completion_tokens=getattr(chat_usage, "completion_tokens", None),
|
||||
max_output_tokens=echoed.get("max_output_tokens"),
|
||||
)
|
||||
|
||||
responses_api_response: Final[ResponsesAPIResponse] = ResponsesAPIResponse(
|
||||
|
|
@ -2431,32 +2480,33 @@ class LiteLLMCompletionResponsesConfig:
|
|||
object="response",
|
||||
error=getattr(chat_completion_response, "error", None),
|
||||
incomplete_details=incomplete_details,
|
||||
instructions=getattr(chat_completion_response, "instructions", None),
|
||||
metadata=getattr(chat_completion_response, "metadata", {}),
|
||||
instructions=echoed.get("instructions"),
|
||||
metadata=echoed.get("metadata") or {},
|
||||
output=LiteLLMCompletionResponsesConfig._transform_chat_completion_choices_to_responses_output(
|
||||
chat_completion_response=chat_completion_response,
|
||||
choices=getattr(chat_completion_response, "choices", []),
|
||||
responses_api_request=responses_api_request,
|
||||
),
|
||||
parallel_tool_calls=getattr(chat_completion_response, "parallel_tool_calls", False),
|
||||
temperature=getattr(chat_completion_response, "temperature", 0),
|
||||
parallel_tool_calls=echoed.get("parallel_tool_calls", False),
|
||||
temperature=echoed.get("temperature"),
|
||||
tool_choice=LiteLLMCompletionResponsesConfig._transform_tool_choice_for_responses_api_response(
|
||||
responses_api_request.get("tool_choice")
|
||||
),
|
||||
tools=getattr(chat_completion_response, "tools", []),
|
||||
top_p=getattr(chat_completion_response, "top_p", None),
|
||||
max_output_tokens=getattr(chat_completion_response, "max_output_tokens", None),
|
||||
previous_response_id=getattr(chat_completion_response, "previous_response_id", None),
|
||||
reasoning=None,
|
||||
tools=echoed.get("tools") or [],
|
||||
top_p=echoed.get("top_p"),
|
||||
max_output_tokens=echoed.get("max_output_tokens"),
|
||||
previous_response_id=echoed.get("previous_response_id"),
|
||||
reasoning=echoed.get("reasoning"),
|
||||
status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status(
|
||||
finish_reason
|
||||
),
|
||||
text={},
|
||||
truncation=getattr(chat_completion_response, "truncation", None),
|
||||
text=echoed.get("text") or {},
|
||||
truncation=echoed.get("truncation"),
|
||||
usage=LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(
|
||||
chat_completion_response=chat_completion_response
|
||||
),
|
||||
user=getattr(chat_completion_response, "user", None),
|
||||
user=echoed.get("user"),
|
||||
store=echoed.get("store"),
|
||||
)
|
||||
responses_api_response._hidden_params = getattr(chat_completion_response, "_hidden_params", {})
|
||||
|
||||
|
|
|
|||
|
|
@ -1888,6 +1888,8 @@ RESPONSES_WS_LOGGED_EVENT_TYPES: Final = [
|
|||
"error",
|
||||
]
|
||||
|
||||
_HISTORY_TERMINAL_EVENT_TYPES: Final = frozenset({"response.completed", "response.incomplete"})
|
||||
|
||||
RESPONSES_WS_MASKABLE_TEXT_BLOCK_TYPES: Final = frozenset({"input_text", "output_text", "text"})
|
||||
|
||||
_RESPONSES_WS_FAILURE_EVENT_TYPES: Final = frozenset({"error", "response.failed"})
|
||||
|
|
@ -2671,7 +2673,7 @@ class ManagedResponsesWebSocketHandler:
|
|||
@staticmethod
|
||||
def _extract_response_id(completed_event: _MutableJsonObject) -> str | None:
|
||||
"""
|
||||
Pull the raw (decoded) response ID out of a ``response.completed`` event.
|
||||
Pull the raw (decoded) response ID out of a terminal (``response.completed`` or ``response.incomplete``) event.
|
||||
Returns *None* if the event doesn't contain a usable ID.
|
||||
"""
|
||||
resp_obj: Final = completed_event.get("response", {})
|
||||
|
|
@ -2687,8 +2689,8 @@ class ManagedResponsesWebSocketHandler:
|
|||
completed_event: _MutableJsonObject,
|
||||
) -> list[dict[str, object]]:
|
||||
"""
|
||||
Convert the output items in a ``response.completed`` event into
|
||||
Responses API message dicts suitable for the next turn's ``input``.
|
||||
Convert output items in a terminal (``response.completed`` or ``response.incomplete``) event into Responses
|
||||
API message dicts suitable for the next turn's ``input``.
|
||||
"""
|
||||
resp_obj: Final = completed_event.get("response", {})
|
||||
if not isinstance(resp_obj, dict):
|
||||
|
|
@ -2929,11 +2931,11 @@ class ManagedResponsesWebSocketHandler:
|
|||
"""
|
||||
Stream ``litellm.aresponses`` and forward every chunk over the WebSocket.
|
||||
|
||||
Captures the ``response.completed`` event type from the chunk object
|
||||
directly (before serialization) to avoid a redundant JSON round-trip on
|
||||
every chunk. Returns the completed event dict, or ``None``.
|
||||
Captures the first terminal (``response.completed`` or ``response.incomplete``) event from the chunk object
|
||||
directly (before serialization) to avoid a redundant JSON round-trip on every chunk. Returns the terminal
|
||||
event dict, or ``None``.
|
||||
"""
|
||||
completed_event: _MutableJsonObject | None = None
|
||||
terminal_event: _MutableJsonObject | None = None
|
||||
stream_response: Final = await litellm.aresponses(model=model, **call_kwargs)
|
||||
async for chunk in stream_response:
|
||||
if chunk is None:
|
||||
|
|
@ -2943,31 +2945,31 @@ class ManagedResponsesWebSocketHandler:
|
|||
serialized = self._serialize_chunk(chunk)
|
||||
if serialized is None:
|
||||
continue
|
||||
if chunk_type == "response.completed" and completed_event is None:
|
||||
if chunk_type in _HISTORY_TERMINAL_EVENT_TYPES and terminal_event is None:
|
||||
try:
|
||||
completed_event = _load_json_object(serialized)
|
||||
terminal_event = _load_json_object(serialized)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await self.websocket.send_text(serialized)
|
||||
except Exception as send_exc:
|
||||
verbose_logger.debug("ManagedResponsesWS: error sending chunk to client: %s", send_exc)
|
||||
return completed_event # Client disconnected
|
||||
return completed_event
|
||||
return terminal_event # Client disconnected
|
||||
return terminal_event
|
||||
|
||||
def _save_turn_history(
|
||||
self,
|
||||
completed_event: _MutableJsonObject | None,
|
||||
terminal_event: _MutableJsonObject | None,
|
||||
prior_history: list[dict[str, object]],
|
||||
current_messages: list[dict[str, object]],
|
||||
) -> None:
|
||||
"""Store this turn in in-memory history for future previous_response_id lookups."""
|
||||
if completed_event is None:
|
||||
"""Store this terminal (``response.completed`` or ``response.incomplete``) event for future history lookups."""
|
||||
if terminal_event is None:
|
||||
return
|
||||
new_response_id: Final = self._extract_response_id(completed_event)
|
||||
new_response_id: Final = self._extract_response_id(terminal_event)
|
||||
if not new_response_id:
|
||||
return
|
||||
output_msgs: Final = self._extract_output_messages(completed_event)
|
||||
output_msgs: Final = self._extract_output_messages(terminal_event)
|
||||
all_messages: Final = prior_history + current_messages + output_msgs
|
||||
self._store_history(new_response_id, all_messages)
|
||||
verbose_logger.debug(
|
||||
|
|
@ -2993,7 +2995,8 @@ class ManagedResponsesWebSocketHandler:
|
|||
2. Prepend those messages to the current ``input`` so the model has full
|
||||
conversation context.
|
||||
3. After the stream completes, extract the new response ID and output
|
||||
messages from ``response.completed`` and store them in
|
||||
messages from the terminal (``response.completed`` or
|
||||
``response.incomplete``) event and store them in
|
||||
``self._session_history`` for the next turn.
|
||||
|
||||
This in-memory approach avoids the async DB-write race condition that
|
||||
|
|
@ -3047,13 +3050,13 @@ class ManagedResponsesWebSocketHandler:
|
|||
call_kwargs.update(self.extra_kwargs)
|
||||
|
||||
try:
|
||||
completed_event: Final = await self._stream_and_forward(model, call_kwargs)
|
||||
terminal_event: Final = await self._stream_and_forward(model, call_kwargs)
|
||||
except Exception as exc:
|
||||
verbose_logger.exception("ManagedResponsesWS: error processing response.create: %s", exc)
|
||||
await self._send_error(str(exc))
|
||||
return
|
||||
|
||||
self._save_turn_history(completed_event, prior_history, current_messages)
|
||||
self._save_turn_history(terminal_event, prior_history, current_messages)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main entry point
|
||||
|
|
|
|||
|
|
@ -322,12 +322,14 @@ from litellm.types.utils import (
|
|||
StandardLoggingRoutingDecision,
|
||||
Usage,
|
||||
all_litellm_params,
|
||||
is_server_derived_pricing_key,
|
||||
shared_backend_model_info,
|
||||
)
|
||||
from litellm.types.utils import ModelInfo as ModelMapInfo
|
||||
from litellm.utils import (
|
||||
CustomStreamWrapper,
|
||||
EmbeddingResponse,
|
||||
ModelInfoLookupMiss,
|
||||
ModelResponse,
|
||||
Rules,
|
||||
function_setup,
|
||||
|
|
@ -336,6 +338,8 @@ from litellm.utils import (
|
|||
get_secret,
|
||||
get_utc_datetime,
|
||||
is_region_allowed,
|
||||
lookup_model_info_for_registration,
|
||||
model_info_with_pricing_overrides,
|
||||
provider_rejectable_params,
|
||||
set_live_deployment_replay,
|
||||
)
|
||||
|
|
@ -477,6 +481,8 @@ def _stream_chunks_have_generated_content(chunks: Sequence[ModelResponseStream])
|
|||
_NO_SESSION_KWARGS: Final[Mapping[str, Mapping[str, object]]] = MappingProxyType({})
|
||||
_SESSION_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
_SILENT_MODEL_ADAPTER: Final = TypeAdapter(str | list[str])
|
||||
_MODEL_INFO_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
_LITELLM_PARAMS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
def _as_retry_skipped_deployment_ids(value: object) -> tuple[str, ...]:
|
||||
|
|
@ -8884,6 +8890,20 @@ class Router:
|
|||
params_base_model: Final = deployment.litellm_params.get("base_model")
|
||||
if isinstance(params_base_model, str) and params_base_model:
|
||||
return params_base_model
|
||||
params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(deployment.litellm_params.model_dump(exclude_none=True))
|
||||
invoked_model: Final = params.get("model_id")
|
||||
if (
|
||||
isinstance(invoked_model, str)
|
||||
and invoked_model
|
||||
and (
|
||||
deployment.litellm_params.custom_llm_provider == "bedrock"
|
||||
or deployment.litellm_params.model.startswith("bedrock/")
|
||||
)
|
||||
):
|
||||
profile_model: Final = invoked_model if invoked_model.startswith("bedrock/") else f"bedrock/{invoked_model}"
|
||||
profile_info: Final = lookup_model_info_for_registration(model=profile_model, custom_llm_provider="bedrock")
|
||||
if not isinstance(profile_info, ModelInfoLookupMiss):
|
||||
return profile_model
|
||||
return deployment.litellm_params.model
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -9044,39 +9064,7 @@ class Router:
|
|||
litellm_params=litellm_params,
|
||||
model_info=_model_info,
|
||||
)
|
||||
for field in CustomPricingLiteLLMParams.model_fields:
|
||||
if deployment.litellm_params.get(field) is not None:
|
||||
_model_info[field] = deployment.litellm_params[field]
|
||||
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=_model_info,
|
||||
backend_model=deployment.litellm_params.model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
if _model_info.get("input_cost_per_token") is not None:
|
||||
Router._inherit_builtin_cache_pricing(
|
||||
model_info=_model_info,
|
||||
backend_model=deployment.litellm_params.model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
Router._inherit_builtin_service_tier_pricing(
|
||||
model_info=_model_info,
|
||||
backend_model=Router._cost_map_backend_model(deployment),
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
Router._inherit_builtin_tiered_output_rate(
|
||||
model_info=_model_info,
|
||||
backend_model=deployment.litellm_params.model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
|
||||
## REGISTER MODEL INFO IN LITELLM MODEL COST MAP
|
||||
Router._register_deployment_in_model_cost(
|
||||
model_id=deployment.model_info.id,
|
||||
model_info=_model_info,
|
||||
model=deployment.litellm_params.model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
Router._register_deployment_pricing(deployment)
|
||||
|
||||
## Check if LLM Deployment is allowed for this deployment
|
||||
if self.deployment_is_active_for_environment(deployment=deployment) is not True:
|
||||
|
|
@ -10080,7 +10068,7 @@ class Router:
|
|||
return (backend_key,)
|
||||
|
||||
@staticmethod
|
||||
def _deployment_model_cost_payload(deployment: Deployment) -> dict: # mutable-ok: cost-map entry
|
||||
def _deployment_model_cost_payload(deployment: Deployment) -> dict[str, object]: # mutable-ok: cost-map entry
|
||||
"""The ``model_info`` a deployment contributes to ``litellm.model_cost``.
|
||||
|
||||
Custom pricing lives on ``litellm_params`` rather than ``model_info``, and
|
||||
|
|
@ -10088,30 +10076,49 @@ class Router:
|
|||
both are folded back in here. That keeps this reproducible from a
|
||||
deployment alone, which is what lets a refresh rebuild the same entries.
|
||||
"""
|
||||
model_info: Final[dict] = deployment.model_info.model_dump(exclude_none=True) # mutable-ok: built in place
|
||||
model_info: Final = dict(
|
||||
_MODEL_INFO_ADAPTER.validate_python(deployment.model_info.model_dump(exclude_none=True))
|
||||
)
|
||||
for field in CustomPricingLiteLLMParams.model_fields:
|
||||
field_value = deployment.litellm_params.get(field)
|
||||
if field_value is not None:
|
||||
model_info[field] = field_value
|
||||
backend_model: Final = Router._cost_map_backend_model(deployment)
|
||||
params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(deployment.litellm_params.model_dump(exclude_none=True))
|
||||
if (
|
||||
params.get("model_id")
|
||||
and backend_model != deployment.litellm_params.model
|
||||
and (
|
||||
deployment.litellm_params.custom_llm_provider == "bedrock"
|
||||
or deployment.litellm_params.model.startswith("bedrock/")
|
||||
)
|
||||
):
|
||||
profile_info: Final = lookup_model_info_for_registration(model=backend_model, custom_llm_provider="bedrock")
|
||||
if not isinstance(profile_info, ModelInfoLookupMiss):
|
||||
explicit_info: Final = _MODEL_INFO_ADAPTER.validate_python(model_info)
|
||||
priced_info: Final = model_info_with_pricing_overrides(profile_info, explicit_info)
|
||||
model_info.update(
|
||||
{key: value for key, value in priced_info.items() if is_server_derived_pricing_key(key)}
|
||||
)
|
||||
Router._inherit_builtin_base_rates_for_off_peak(
|
||||
model_info=model_info,
|
||||
backend_model=deployment.litellm_params.model,
|
||||
backend_model=backend_model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
if model_info.get("input_cost_per_token") is not None:
|
||||
Router._inherit_builtin_cache_pricing(
|
||||
model_info=model_info,
|
||||
backend_model=deployment.litellm_params.model,
|
||||
backend_model=backend_model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
Router._inherit_builtin_service_tier_pricing(
|
||||
model_info=model_info,
|
||||
backend_model=Router._cost_map_backend_model(deployment),
|
||||
backend_model=backend_model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
Router._inherit_builtin_tiered_output_rate(
|
||||
model_info=model_info,
|
||||
backend_model=deployment.litellm_params.model,
|
||||
backend_model=backend_model,
|
||||
custom_llm_provider=deployment.litellm_params.custom_llm_provider,
|
||||
)
|
||||
return model_info
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ from itertools import groupby
|
|||
from types import MappingProxyType
|
||||
from typing import Any, Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.caching.caching import DualCache
|
||||
|
|
@ -37,6 +39,7 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
)
|
||||
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
|
||||
from litellm.router_strategy.tag_based_routing import _get_tags_from_request_kwargs
|
||||
from litellm.router_utils.batch_utils import batch_charged_once_marker_key, batch_id_to_charge_once
|
||||
from litellm.router_utils.cooldown_callbacks import (
|
||||
_get_prometheus_logger_from_callbacks,
|
||||
)
|
||||
|
|
@ -46,6 +49,8 @@ from litellm.types.utils import BudgetConfig, GenericBudgetConfigType, StandardL
|
|||
from litellm.types.utils import BudgetConfig as GenericBudgetInfo
|
||||
|
||||
DEFAULT_REDIS_SYNC_INTERVAL: Final = 1
|
||||
_CALLBACK_FIELDS: Final = TypeAdapter(dict[str, object])
|
||||
_BATCH_CHARGE_VALUES: Final = TypeAdapter(tuple[object, float])
|
||||
|
||||
|
||||
class _LiteLLMParamsDictView:
|
||||
|
|
@ -491,17 +496,22 @@ class RouterBudgetLimiting(CustomLogger):
|
|||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
"""Original method now uses helper functions"""
|
||||
verbose_router_logger.debug("in RouterBudgetLimiting.async_log_success_event")
|
||||
callback_fields: Final = _CALLBACK_FIELDS.validate_python(kwargs)
|
||||
# WS session wrappers fire with result=None; per-turn costs tracked by inner calls.
|
||||
if kwargs.get("call_type") in ("_aresponses_websocket", "_arealtime"):
|
||||
if callback_fields.get("call_type") in ("_aresponses_websocket", "_arealtime"):
|
||||
return
|
||||
standard_logging_payload: Final[StandardLoggingPayload | None] = kwargs.get("standard_logging_object", None)
|
||||
if standard_logging_payload is None:
|
||||
raise ValueError("standard_logging_payload is required")
|
||||
|
||||
response_cost: Final[float] = standard_logging_payload.get("response_cost", 0)
|
||||
batch_response, response_cost = _BATCH_CHARGE_VALUES.validate_python(
|
||||
(response_obj, _CALLBACK_FIELDS.validate_python(standard_logging_payload).get("response_cost", 0))
|
||||
)
|
||||
model_id: Final[str] = str(standard_logging_payload.get("model_id", ""))
|
||||
custom_llm_provider: Final[str | None] = standard_logging_payload.get("custom_llm_provider")
|
||||
|
||||
batch_id: Final = batch_id_to_charge_once(callback_fields.get("call_type"), batch_response, response_cost)
|
||||
|
||||
budget_config: Final = (
|
||||
self._get_budget_config_for_provider(custom_llm_provider) if custom_llm_provider is not None else None
|
||||
)
|
||||
|
|
@ -514,6 +524,7 @@ class RouterBudgetLimiting(CustomLogger):
|
|||
spend_key=spend_key,
|
||||
start_time_key=start_time_key,
|
||||
response_cost=response_cost,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
deployment_budget_config: Final = self._get_budget_config_for_deployment(model_id)
|
||||
|
|
@ -526,11 +537,12 @@ class RouterBudgetLimiting(CustomLogger):
|
|||
spend_key=deployment_spend_key,
|
||||
start_time_key=deployment_start_time_key,
|
||||
response_cost=response_cost,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
request_tags: Final = _get_tags_from_request_kwargs(
|
||||
kwargs,
|
||||
metadata_variable_name=get_metadata_variable_name_from_kwargs(kwargs or {}),
|
||||
callback_fields,
|
||||
metadata_variable_name=get_metadata_variable_name_from_kwargs(callback_fields),
|
||||
)
|
||||
if len(request_tags) > 0:
|
||||
for _tag in request_tags:
|
||||
|
|
@ -543,6 +555,7 @@ class RouterBudgetLimiting(CustomLogger):
|
|||
spend_key=_tag_spend_key,
|
||||
start_time_key=_tag_start_time_key,
|
||||
response_cost=response_cost,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
async def _increment_spend_for_key(
|
||||
|
|
@ -551,12 +564,15 @@ class RouterBudgetLimiting(CustomLogger):
|
|||
spend_key: str,
|
||||
start_time_key: str,
|
||||
response_cost: float,
|
||||
batch_id: str | None = None,
|
||||
):
|
||||
if budget_config.budget_duration is None:
|
||||
return
|
||||
|
||||
current_time: Final = datetime.now(timezone.utc).timestamp()
|
||||
ttl_seconds: Final = duration_in_seconds(budget_config.budget_duration)
|
||||
if batch_id is not None and not await self._claim_batch_charge(spend_key, batch_id, ttl_seconds):
|
||||
return
|
||||
|
||||
budget_start = await self._get_or_set_budget_start_time(
|
||||
start_time_key=start_time_key,
|
||||
|
|
@ -594,6 +610,17 @@ class RouterBudgetLimiting(CustomLogger):
|
|||
|
||||
verbose_router_logger.debug("Incremented spend for %s by %s", spend_key, response_cost)
|
||||
|
||||
async def _claim_batch_charge(self, spend_key: str, batch_id: str, ttl_seconds: int) -> bool:
|
||||
marker_key: Final = batch_charged_once_marker_key(spend_key=spend_key, batch_id=batch_id)
|
||||
redis_cache: Final = self.dual_cache.redis_cache
|
||||
if redis_cache is not None:
|
||||
polls: Final = await redis_cache.async_increment(key=marker_key, value=1, ttl=ttl_seconds, refresh_ttl=True)
|
||||
return polls == 1
|
||||
local_polls: Final = await self.dual_cache.in_memory_cache.async_increment(
|
||||
key=marker_key, value=1, ttl=ttl_seconds, refresh_ttl=True
|
||||
)
|
||||
return local_polls == 1
|
||||
|
||||
async def periodic_sync_in_memory_spend_with_redis(self):
|
||||
"""
|
||||
Handler that triggers sync_in_memory_spend_with_redis every DEFAULT_REDIS_SYNC_INTERVAL seconds
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import json
|
|||
from os import PathLike
|
||||
from typing import Final
|
||||
|
||||
from openai.types import Batch
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.types.llms.openai import FileTypes, OpenAIFilesPurpose
|
||||
from litellm.types.utils import CallTypes
|
||||
|
|
@ -189,3 +191,14 @@ def is_batch_retrieve_call_type(call_type: object) -> bool:
|
|||
routing strategies' own state, must not be fed from it.
|
||||
"""
|
||||
return isinstance(call_type, str) and call_type in BATCH_RETRIEVE_CALL_TYPES
|
||||
|
||||
|
||||
def batch_charged_once_marker_key(spend_key: str, batch_id: str) -> str:
|
||||
return f"{spend_key}:batch:{batch_id}"
|
||||
|
||||
|
||||
def batch_id_to_charge_once(call_type: object, response_obj: object, response_cost: float) -> str | None:
|
||||
"""A finished batch reports its whole cost on every poll, so its id is charged once per counter."""
|
||||
if response_cost <= 0 or not is_batch_retrieve_call_type(call_type):
|
||||
return None
|
||||
return response_obj.id if isinstance(response_obj, Batch) else None
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -292,6 +292,8 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
input_cost_per_token_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing
|
||||
cache_creation_input_token_cost: float | None
|
||||
cache_creation_input_token_cost_above_200k_tokens: float | None
|
||||
cache_creation_input_token_cost_above_100k_tokens: ReadOnly[float | None]
|
||||
cache_creation_input_token_cost_above_1hr_above_100k_tokens: ReadOnly[float | None]
|
||||
cache_creation_input_token_cost_above_272k_tokens: float | None
|
||||
cache_creation_input_token_cost_above_272k_tokens_priority: float | None
|
||||
cache_creation_input_token_cost_above_272k_tokens_flex: float | None
|
||||
|
|
@ -308,6 +310,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
cache_read_input_token_cost_balanced: ReadOnly[float | None]
|
||||
cache_read_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing
|
||||
cache_read_input_token_cost_above_200k_tokens: float | None
|
||||
cache_read_input_token_cost_above_100k_tokens: ReadOnly[float | None]
|
||||
cache_read_input_token_cost_above_200k_tokens_priority: float | None
|
||||
cache_read_input_token_cost_above_272k_tokens: float | None
|
||||
cache_read_input_token_cost_above_272k_tokens_priority: float | None
|
||||
|
|
@ -316,9 +319,11 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
cache_read_input_token_cost_above_512k_tokens: float | None
|
||||
cache_read_input_token_cost_batches: ReadOnly[float | None]
|
||||
cache_read_input_token_cost_above_200k_tokens_batches: ReadOnly[float | None]
|
||||
cache_read_input_token_cost_above_100k_tokens_batches: ReadOnly[float | None]
|
||||
cache_read_input_token_cost_above_272k_tokens_batches: ReadOnly[float | None]
|
||||
cache_creation_input_token_cost_batches: ReadOnly[float | None]
|
||||
cache_creation_input_token_cost_above_200k_tokens_batches: ReadOnly[float | None]
|
||||
cache_creation_input_token_cost_above_100k_tokens_batches: ReadOnly[float | None]
|
||||
cache_creation_input_token_cost_above_272k_tokens_batches: ReadOnly[float | None]
|
||||
# Smallest prefix this model will actually cache, whatever caching mechanism its provider uses.
|
||||
# Absent means the provider-agnostic default applies; see MINIMUM_PROMPT_CACHE_TOKEN_COUNT.
|
||||
|
|
@ -328,6 +333,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
input_cost_per_audio_token: float | None
|
||||
input_cost_per_token_above_128k_tokens: float | None # only for vertex ai models
|
||||
input_cost_per_token_above_200k_tokens: float | None # only for vertex ai gemini-2.5-pro models
|
||||
input_cost_per_token_above_100k_tokens: ReadOnly[float | None]
|
||||
input_cost_per_token_above_200k_tokens_priority: float | None
|
||||
input_cost_per_token_above_272k_tokens: float | None # GPT-5.4/5.4-pro: prompts >272K priced at 2x input
|
||||
input_cost_per_token_above_272k_tokens_priority: float | None
|
||||
|
|
@ -348,9 +354,11 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
input_cost_per_token_batches: float | None
|
||||
input_cost_per_video_token_batches: ReadOnly[float | None]
|
||||
input_cost_per_token_above_200k_tokens_batches: ReadOnly[float | None]
|
||||
input_cost_per_token_above_100k_tokens_batches: ReadOnly[float | None]
|
||||
input_cost_per_token_above_272k_tokens_batches: ReadOnly[float | None]
|
||||
output_cost_per_token_batches: float | None
|
||||
output_cost_per_token_above_200k_tokens_batches: ReadOnly[float | None]
|
||||
output_cost_per_token_above_100k_tokens_batches: ReadOnly[float | None]
|
||||
output_cost_per_token_above_272k_tokens_batches: ReadOnly[float | None]
|
||||
output_cost_per_token: Required[float | None]
|
||||
output_cost_per_token_flex: float | None # OpenAI flex service tier pricing
|
||||
|
|
@ -370,6 +378,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
output_cost_per_audio_token: float | None
|
||||
output_cost_per_token_above_128k_tokens: float | None # only for vertex ai models
|
||||
output_cost_per_token_above_200k_tokens: float | None # only for vertex ai gemini-2.5-pro models
|
||||
output_cost_per_token_above_100k_tokens: ReadOnly[float | None]
|
||||
output_cost_per_token_above_200k_tokens_priority: float | None
|
||||
output_cost_per_token_above_272k_tokens: float | None # GPT-5.4/5.4-pro: prompts >272K priced at 1.5x output
|
||||
output_cost_per_token_above_272k_tokens_priority: float | None
|
||||
|
|
@ -3760,6 +3769,8 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
|
|||
input_cost_per_token_balanced: float | None = None
|
||||
input_cost_per_token_ultrafast: float | None = None
|
||||
cache_creation_input_token_cost_above_1hr: float | None = None
|
||||
cache_creation_input_token_cost_above_100k_tokens: float | None = None
|
||||
cache_creation_input_token_cost_above_1hr_above_100k_tokens: float | None = None
|
||||
cache_creation_input_token_cost_above_200k_tokens: float | None = None
|
||||
cache_creation_input_token_cost_above_272k_tokens: float | None = None
|
||||
cache_creation_input_token_cost_above_272k_tokens_priority: float | None = None
|
||||
|
|
@ -3773,15 +3784,18 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
|
|||
cache_read_input_token_cost_priority: float | None = None
|
||||
cache_read_input_token_cost_balanced: float | None = None
|
||||
cache_read_input_token_cost_ultrafast: float | None = None
|
||||
cache_read_input_token_cost_above_100k_tokens: float | None = None
|
||||
cache_read_input_token_cost_above_200k_tokens: float | None = None
|
||||
cache_read_input_token_cost_above_200k_tokens_priority: float | None = None
|
||||
cache_read_input_token_cost_above_272k_tokens_priority: float | None = None
|
||||
cache_read_input_token_cost_above_272k_tokens_flex: float | None = None
|
||||
cache_read_input_token_cost_above_272k_tokens_ultrafast: float | None = None
|
||||
cache_read_input_token_cost_batches: float | None = None
|
||||
cache_read_input_token_cost_above_100k_tokens_batches: float | None = None
|
||||
cache_read_input_token_cost_above_200k_tokens_batches: float | None = None
|
||||
cache_read_input_token_cost_above_272k_tokens_batches: float | None = None
|
||||
cache_creation_input_token_cost_batches: float | None = None
|
||||
cache_creation_input_token_cost_above_100k_tokens_batches: float | None = None
|
||||
cache_creation_input_token_cost_above_200k_tokens_batches: float | None = None
|
||||
cache_creation_input_token_cost_above_272k_tokens_batches: float | None = None
|
||||
cache_read_input_audio_token_cost: float | None = None
|
||||
|
|
@ -3790,12 +3804,14 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
|
|||
input_cost_per_audio_token: float | None = None
|
||||
input_cost_per_token_cache_hit: float | None = None
|
||||
input_cost_per_token_above_128k_tokens: float | None = None
|
||||
input_cost_per_token_above_100k_tokens: float | None = None
|
||||
input_cost_per_token_above_200k_tokens: float | None = None
|
||||
input_cost_per_token_above_200k_tokens_priority: float | None = None
|
||||
input_cost_per_token_above_272k_tokens_priority: float | None = None
|
||||
input_cost_per_token_above_272k_tokens_flex: float | None = None
|
||||
input_cost_per_token_above_272k_tokens_ultrafast: float | None = None
|
||||
input_cost_per_token_above_200k_tokens_batches: float | None = None
|
||||
input_cost_per_token_above_100k_tokens_batches: float | None = None
|
||||
input_cost_per_token_above_272k_tokens_batches: float | None = None
|
||||
input_cost_per_query: float | None = None
|
||||
input_cost_per_image: float | None = None
|
||||
|
|
@ -3817,12 +3833,14 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
|
|||
output_cost_per_token_ultrafast: float | None = None
|
||||
output_cost_per_audio_token: float | None = None
|
||||
output_cost_per_token_above_128k_tokens: float | None = None
|
||||
output_cost_per_token_above_100k_tokens: float | None = None
|
||||
output_cost_per_token_above_200k_tokens: float | None = None
|
||||
output_cost_per_token_above_200k_tokens_priority: float | None = None
|
||||
output_cost_per_token_above_272k_tokens_priority: float | None = None
|
||||
output_cost_per_token_above_272k_tokens_flex: float | None = None
|
||||
output_cost_per_token_above_272k_tokens_ultrafast: float | None = None
|
||||
output_cost_per_token_above_200k_tokens_batches: float | None = None
|
||||
output_cost_per_token_above_100k_tokens_batches: float | None = None
|
||||
output_cost_per_token_above_272k_tokens_batches: float | None = None
|
||||
output_cost_per_character_above_128k_tokens: float | None = None
|
||||
output_cost_per_image: float | None = None
|
||||
|
|
@ -3889,7 +3907,7 @@ def shared_backend_model_info(model_info: dict[str, Any]) -> dict[str, Any]:
|
|||
return {k: v for k, v in model_info.items() if k in SHARED_BACKEND_MODEL_INFO_FIELDS}
|
||||
|
||||
|
||||
ABOVE_THRESHOLD_COST_KEY_PATTERN: Final = re.compile(r"_above_\d+k?_tokens$")
|
||||
ABOVE_THRESHOLD_COST_KEY_PATTERN: Final = re.compile(r"_above_\d+k?_tokens(?:_batches)?$")
|
||||
|
||||
_PRICING_FIELD_EXEMPTIONS: Final[frozenset[str]] = frozenset({"output_vector_size"})
|
||||
|
||||
|
|
|
|||
143
litellm/utils.py
143
litellm/utils.py
|
|
@ -45,7 +45,7 @@ from httpx import Proxy
|
|||
from httpx._utils import get_environment_proxies
|
||||
from openai.lib import _parsing, _pydantic
|
||||
from openai.types.chat.completion_create_params import ResponseFormat
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, with_config
|
||||
|
||||
import litellm
|
||||
import litellm.litellm_core_utils
|
||||
|
|
@ -3247,6 +3247,141 @@ def _get_builtin_model_info_for_registration(model: str) -> ModelInfo | None:
|
|||
_runtime_registered_model_cost: Final[dict[str, dict[str, object]]] = {} # mutable-ok: replayed on reload
|
||||
|
||||
|
||||
class _RegisteredPricingFields:
|
||||
"""Fields each register_model call set, per cost-map key, for the cost map they were written to.
|
||||
|
||||
A catalog reload swaps litellm.model_cost for a new object, which drops these the way main drops
|
||||
an alias entry's registered_fields; persisted registrations are replayed onto the new map."""
|
||||
|
||||
cost_map_id: int = 0
|
||||
fields: dict[str, dict[str, object]] = {} # mutable-ok: reset with each new cost map
|
||||
|
||||
@classmethod
|
||||
def current(cls) -> dict[str, dict[str, object]]:
|
||||
if cls.cost_map_id != id(litellm.model_cost):
|
||||
cls.cost_map_id = id(litellm.model_cost)
|
||||
cls.fields = {}
|
||||
return cls.fields
|
||||
|
||||
@classmethod
|
||||
def record(cls, key: str, value: Mapping[str, object]) -> None:
|
||||
registered: Final = cls.current()
|
||||
registered[key] = _update_dictionary(dict(registered.get(key, {})), dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelInfoLookupMiss:
|
||||
tripped: bool
|
||||
|
||||
|
||||
def lookup_model_info_for_registration(model: str, custom_llm_provider: str | None) -> ModelInfo | ModelInfoLookupMiss:
|
||||
try:
|
||||
info: Final = get_model_info(model=model, custom_llm_provider=custom_llm_provider)
|
||||
except ModelNotMappedError:
|
||||
return ModelInfoLookupMiss(tripped=False)
|
||||
except Exception:
|
||||
return ModelInfoLookupMiss(tripped=True)
|
||||
return ModelInfoLookupMiss(tripped=False) if is_generalized_model_info(info) else info
|
||||
|
||||
|
||||
def runtime_model_cost_registrations(
|
||||
model: str, custom_llm_provider: str
|
||||
) -> tuple[tuple[str, Mapping[str, object]], ...]:
|
||||
names: Final = _get_potential_model_names(model, custom_llm_provider)
|
||||
candidates: Final = (
|
||||
model,
|
||||
names["combined_model_name"],
|
||||
f"{custom_llm_provider}/{names['split_model']}",
|
||||
names["split_model"],
|
||||
names["stripped_model_name"],
|
||||
)
|
||||
return tuple(
|
||||
(key, MappingProxyType({name: value for name, value in entry.items() if value is not None}))
|
||||
for key, entry in ((key, _RegisteredPricingFields.current().get(key)) for key in dict.fromkeys(candidates))
|
||||
if entry
|
||||
)
|
||||
|
||||
|
||||
_TOKEN_COST_FIELDS: Final = ("input_cost_per_token", "output_cost_per_token")
|
||||
_DIRECTION_RATE_FIELDS: Final = MappingProxyType(
|
||||
{
|
||||
"input_cost_per_token": ("input_cost_per_token",),
|
||||
"output_cost_per_token": ("output_cost_per_token", "output_cost_per_reasoning_token"),
|
||||
}
|
||||
)
|
||||
_RATE_TABLE: Final = TypeAdapter(Mapping[str, object])
|
||||
_RATE_TIERS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
|
||||
|
||||
|
||||
def _is_overridden_rate(name: str, overridden_fields: tuple[str, ...]) -> bool:
|
||||
is_variant: Final = name.startswith(tuple(f"{rate_field}_" for rate_field in overridden_fields))
|
||||
return name in overridden_fields or (is_variant and "_cache_" not in name)
|
||||
|
||||
|
||||
def _without_overridden_rates(rates: Mapping[str, object], overridden_fields: tuple[str, ...]) -> Mapping[str, object]:
|
||||
return MappingProxyType(
|
||||
{name: rate for name, rate in rates.items() if not _is_overridden_rate(name, overridden_fields)}
|
||||
)
|
||||
|
||||
|
||||
def _rate_table_with_flat_prices(
|
||||
name: str, table: object, overridden_fields: tuple[str, ...], flat_prices: Mapping[str, object]
|
||||
) -> object:
|
||||
try:
|
||||
if name == "tiered_pricing":
|
||||
return [
|
||||
{**_without_overridden_rates(tier, overridden_fields), **flat_prices}
|
||||
for tier in _RATE_TIERS.validate_python(table)
|
||||
]
|
||||
if name == "off_peak_pricing":
|
||||
return dict(_without_overridden_rates(_RATE_TABLE.validate_python(table), overridden_fields))
|
||||
except ValidationError:
|
||||
return table
|
||||
return table
|
||||
|
||||
|
||||
def _without_overridden_price_variants(
|
||||
entry: Mapping[str, object], value: Mapping[str, object]
|
||||
) -> Mapping[str, object]:
|
||||
"""A flat ``input_cost_per_token`` / ``output_cost_per_token`` replaces every inherited rate that would stand in
|
||||
for it (token thresholds, service tiers, batches, ``tiered_pricing`` tiers, off-peak windows, and the reasoning
|
||||
rate for output)."""
|
||||
flat_prices: Final = MappingProxyType(
|
||||
{cost_field: value[cost_field] for cost_field in _TOKEN_COST_FIELDS if value.get(cost_field) is not None}
|
||||
)
|
||||
overridden_fields: Final = tuple(
|
||||
itertools.chain.from_iterable(_DIRECTION_RATE_FIELDS[cost_field] for cost_field in flat_prices)
|
||||
)
|
||||
if not overridden_fields:
|
||||
return entry
|
||||
return MappingProxyType(
|
||||
{
|
||||
name: _rate_table_with_flat_prices(name, field_value, overridden_fields, flat_prices)
|
||||
for name, field_value in entry.items()
|
||||
if not _is_overridden_rate(name, overridden_fields)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow"))
|
||||
class _PricingModelInfo(ModelInfo):
|
||||
pass
|
||||
|
||||
|
||||
_PRICING_MODEL_INFO: Final = TypeAdapter(_PricingModelInfo)
|
||||
|
||||
|
||||
def model_info_with_pricing_overrides(model_info: ModelInfo, registration: Mapping[str, object]) -> ModelInfo:
|
||||
from litellm.types.utils import is_server_derived_pricing_key
|
||||
|
||||
overrides: Final = {
|
||||
name: value for name, value in registration.items() if value is not None and is_server_derived_pricing_key(name)
|
||||
}
|
||||
return _PRICING_MODEL_INFO.validate_python(
|
||||
_update_dictionary(dict(_without_overridden_price_variants(model_info, overrides)), overrides)
|
||||
)
|
||||
|
||||
|
||||
class _LiveDeploymentReplay:
|
||||
"""Single-slot holder for the callback that rebuilds live router deployments.
|
||||
|
||||
|
|
@ -3349,7 +3484,10 @@ def register_model(
|
|||
if persist_across_reloads:
|
||||
_registrations: Final[Mapping[str, Mapping[str, object]]] = loaded_model_cost
|
||||
for _registered_key, _registered_value in _registrations.items():
|
||||
_runtime_registered_model_cost[_registered_key] = dict(_registered_value)
|
||||
_runtime_registered_model_cost[_registered_key] = _update_dictionary(
|
||||
dict(_runtime_registered_model_cost.get(_registered_key, {})),
|
||||
dict(_RATE_TABLE.validate_python(_registered_value)),
|
||||
)
|
||||
|
||||
_skip_get_model_info_providers: Final = PROVIDERS_THAT_AUTHENTICATE_ON_PROVIDER_INFO
|
||||
|
||||
|
|
@ -3415,6 +3553,7 @@ def register_model(
|
|||
## override / add new keys to the existing model cost dictionary
|
||||
updated_dictionary = _update_dictionary(existing_model, value)
|
||||
litellm.model_cost.setdefault(model_cost_key, {}).update(updated_dictionary)
|
||||
_RegisteredPricingFields.record(str(model_cost_key), value)
|
||||
|
||||
# Invalidate case-insensitive lookup map since model_cost was modified
|
||||
_invalidate_model_cost_lowercase_map()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -83,7 +83,12 @@
|
|||
"minimum": 0,
|
||||
"description": "USD per token written to the provider's prompt cache."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_128k_tokens": {
|
||||
"cache_creation_input_token_cost_above_100k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_100k_tokens_batches": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
|
|
@ -93,6 +98,11 @@
|
|||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_1hr_above_100k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -103,16 +113,6 @@
|
|||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_200k_tokens_batches": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_256k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_272k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -123,6 +123,11 @@
|
|||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_272k_tokens_decisions": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_272k_tokens_flex": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -138,15 +143,14 @@
|
|||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_creation_input_token_cost_above_32k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_creation_input_token_cost_batches": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"cache_creation_input_token_cost_decisions": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"cache_creation_input_token_cost_flex": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -174,7 +178,12 @@
|
|||
"minimum": 0,
|
||||
"description": "USD per prompt token served from the provider's prompt cache."
|
||||
},
|
||||
"cache_read_input_token_cost_above_128k_tokens": {
|
||||
"cache_read_input_token_cost_above_100k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_read_input_token_cost_above_100k_tokens_batches": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
|
|
@ -194,11 +203,6 @@
|
|||
"minimum": 0,
|
||||
"description": "Priority service-tier rate for the same-named base field."
|
||||
},
|
||||
"cache_read_input_token_cost_above_256k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_read_input_token_cost_above_272k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -209,6 +213,11 @@
|
|||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_read_input_token_cost_above_272k_tokens_decisions": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -224,11 +233,6 @@
|
|||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_read_input_token_cost_above_32k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"cache_read_input_token_cost_above_512k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -242,6 +246,10 @@
|
|||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"cache_read_input_token_cost_decisions": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"cache_read_input_token_cost_flex": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -289,6 +297,10 @@
|
|||
"format": "date",
|
||||
"pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$"
|
||||
},
|
||||
"explicit_cache_read_input_token_cost": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"gemini_audio_only_live": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
@ -372,11 +384,25 @@
|
|||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"input_cost_per_text_message": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"input_cost_per_token": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "USD per prompt token."
|
||||
},
|
||||
"input_cost_per_token_above_100k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"input_cost_per_token_above_100k_tokens_batches": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"input_cost_per_token_above_128k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -397,11 +423,6 @@
|
|||
"minimum": 0,
|
||||
"description": "Priority service-tier rate for the same-named base field."
|
||||
},
|
||||
"input_cost_per_token_above_256k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"input_cost_per_token_above_272k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -412,6 +433,11 @@
|
|||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"input_cost_per_token_above_272k_tokens_decisions": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"input_cost_per_token_above_272k_tokens_flex": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -427,11 +453,6 @@
|
|||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"input_cost_per_token_above_32k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"input_cost_per_token_above_512k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -450,6 +471,10 @@
|
|||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"input_cost_per_token_decisions": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"input_cost_per_token_flex": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -528,6 +553,7 @@
|
|||
"completion",
|
||||
"embedding",
|
||||
"evaluation",
|
||||
"extraction",
|
||||
"guardrail",
|
||||
"image_edit",
|
||||
"image_generation",
|
||||
|
|
@ -710,6 +736,10 @@
|
|||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_cost_per_image_token_batches": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_cost_per_pixel": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
|
|
@ -752,6 +782,16 @@
|
|||
"minimum": 0,
|
||||
"description": "USD per generated token."
|
||||
},
|
||||
"output_cost_per_token_above_100k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"output_cost_per_token_above_100k_tokens_batches": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"output_cost_per_token_above_128k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -772,11 +812,6 @@
|
|||
"minimum": 0,
|
||||
"description": "Priority service-tier rate for the same-named base field."
|
||||
},
|
||||
"output_cost_per_token_above_256k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"output_cost_per_token_above_272k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -787,6 +822,11 @@
|
|||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"output_cost_per_token_above_272k_tokens_decisions": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"output_cost_per_token_above_272k_tokens_flex": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -802,11 +842,6 @@
|
|||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"output_cost_per_token_above_32k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Rate applied once the prompt exceeds the token threshold in the field name."
|
||||
},
|
||||
"output_cost_per_token_above_512k_tokens": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -821,6 +856,10 @@
|
|||
"minimum": 0,
|
||||
"description": "USD per generated token via the provider's batch API."
|
||||
},
|
||||
"output_cost_per_token_decisions": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_cost_per_token_flex": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -1017,12 +1056,24 @@
|
|||
"supports_audio_output": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_bedrock_converse_tool_result_images": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_bedrock_native_structured_output": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_bedrock_runtime_chat_completions_inline_reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_bedrock_runtime_chat_completions_response_format": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_bedrock_runtime_chat_completions_tools_with_reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_box_questions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_computer_use": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
@ -1095,6 +1146,9 @@
|
|||
"supports_reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_regex_lookaround": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_response_schema": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
@ -1193,6 +1247,31 @@
|
|||
"minimum": 0,
|
||||
"description": "Tokens added once per request to a local count estimate, for the provider's chat framing."
|
||||
},
|
||||
"token_count_model": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string",
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"litellm_provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bedrock",
|
||||
"bedrock_mantle"
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "Model that counts tokens for this one, its counting provider, and the AWS region its count API runs in."
|
||||
},
|
||||
"token_count_tokenizer": {
|
||||
"type": "string",
|
||||
"description": "Tokenizer litellm counts this model's requests with when the provider cannot count them.",
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ proxy = [
|
|||
"httpx2>=2.5.0,<3",
|
||||
"pydantic>=2.12.0,<3",
|
||||
"litellm-proxy-extras==0.4.105",
|
||||
"litellm-enterprise==0.1.73",
|
||||
"litellm-enterprise==0.1.73.post1",
|
||||
"RestrictedPython>=8.5,<9.0",
|
||||
"rich>=13.9.4,<14.0",
|
||||
"InquirerPy>=0.3.4,<1.0",
|
||||
|
|
@ -283,7 +283,7 @@ ci = [
|
|||
"langchain-openai==1.1.14",
|
||||
"langgraph>=1.2.4,<1.3.0",
|
||||
"langgraph-prebuilt>=1.1.0,<1.3.0",
|
||||
"claude-agent-sdk==0.1.44",
|
||||
"claude-agent-sdk==0.2.165",
|
||||
"google-cloud-speech==2.40.0",
|
||||
]
|
||||
healthcheck = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,349 @@
|
|||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from queue import SimpleQueue
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import owned_proxy_process
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from integration.messages_endpoint.providers.anthropic.test_anthropic_safeguards_beta_wire import (
|
||||
ANTHROPIC_KEY,
|
||||
ANTHROPIC_MODEL,
|
||||
BETA,
|
||||
OMITTED,
|
||||
anthropic_peer,
|
||||
marker_of,
|
||||
messages_body,
|
||||
stream_frames,
|
||||
)
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
_CONFIG_MODEL: Final = "anthropic-safeguards-chaos"
|
||||
_FOUNDRY_CONFIG_MODEL: Final = "azure-ai-safeguards-chaos"
|
||||
_YAML_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_MARKER: Final = re.compile(r"marker-([0-9a-f]{32})")
|
||||
_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
|
||||
_MODEL_LIST: Final = json.dumps(
|
||||
{"object": "list", "data": [{"id": ANTHROPIC_MODEL, "object": "model", "owned_by": "anthropic"}]}
|
||||
).encode()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Call:
|
||||
stream: bool
|
||||
marker: str
|
||||
safeguards: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Served:
|
||||
call: _Call
|
||||
status: int
|
||||
text: str
|
||||
|
||||
|
||||
def _calls(count: int, stream: bool, safeguards: bool = True) -> tuple[_Call, ...]:
|
||||
return tuple(_Call(stream=stream, marker=uuid.uuid4().hex, safeguards=safeguards) for _ in range(count))
|
||||
|
||||
|
||||
def _streamed_reply(marker: str, abort_after: int | None = None, pause: float = 0) -> Reply:
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=stream_frames(marker),
|
||||
abort_after=abort_after,
|
||||
pause_between_chunks=pause,
|
||||
)
|
||||
|
||||
|
||||
async def _send(client: httpx.AsyncClient, key: str, model: str, call: _Call) -> _Served:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
json=(
|
||||
messages_body(model, call.marker, stream=call.stream)
|
||||
if call.safeguards
|
||||
else messages_body(model, call.marker, stream=call.stream, safeguards=OMITTED)
|
||||
),
|
||||
headers={"Authorization": f"Bearer {key}", "anthropic-version": "2023-06-01"},
|
||||
) as response:
|
||||
raw: Final = await response.aread()
|
||||
return _Served(call=call, status=response.status_code, text=raw.decode())
|
||||
|
||||
|
||||
async def _burst(
|
||||
base_url: str, key: str, model: str, calls: tuple[_Call, ...], *, tolerate_transport_errors: bool = False
|
||||
) -> tuple[_Served, ...]:
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=60, trust_env=False) as client:
|
||||
results: Final = await asyncio.gather(
|
||||
*(_send(client, key, model, call) for call in calls), return_exceptions=tolerate_transport_errors
|
||||
)
|
||||
for result in results:
|
||||
assert not isinstance(result, BaseException) or isinstance(result, httpx.TransportError), repr(result)
|
||||
return tuple(result for result in results if isinstance(result, _Served))
|
||||
|
||||
|
||||
def _assert_answered_with_its_own_marker(served: _Served) -> None:
|
||||
assert served.status == 200, served.text
|
||||
assert set(_MARKER.findall(served.text)) == {served.call.marker}, served.text
|
||||
assert f"msg_{served.call.marker}" in served.text, served.text
|
||||
if served.call.stream:
|
||||
assert "event: message_stop" in served.text, served.text
|
||||
|
||||
|
||||
def _assert_every_request_carried_the_beta(received: tuple[Request, ...], markers: frozenset[str]) -> None:
|
||||
assert sorted(marker_of(request) for request in received) == sorted(markers)
|
||||
missing: Final = [
|
||||
frozenset(request.headers) for request in received if request.headers.get("anthropic-beta") != BETA
|
||||
]
|
||||
assert missing == [], missing
|
||||
|
||||
|
||||
def _spend_success_ids(model: str, expected: frozenset[str]) -> None:
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT request_id FROM "LiteLLM_SpendLogs" WHERE model_group=%s AND status=%s', (model, "success")
|
||||
),
|
||||
lambda found: expected <= {str(row["request_id"]) for row in found},
|
||||
seconds=70,
|
||||
)
|
||||
identities: Final = [row["request_id"] for row in rows]
|
||||
assert len(set(identities)) == len(identities), identities
|
||||
|
||||
|
||||
async def test_burst_with_aborted_streams_keeps_the_beta_on_every_forwarded_request(gateway: Gateway) -> None:
|
||||
calls: Final = tuple(_Call(stream=index % 2 == 0, marker=uuid.uuid4().hex) for index in range(30))
|
||||
aborted: Final = frozenset(call.marker for index, call in enumerate(calls) if call.stream and index % 3 == 0)
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
marker: Final = marker_of(request)
|
||||
if marker in aborted:
|
||||
return _streamed_reply(marker, abort_after=0)
|
||||
return anthropic_peer(request)
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY)
|
||||
served: Final = await _burst(str(gateway.client.base_url), gateway.key, model, calls)
|
||||
assert len(served) == 30
|
||||
for item in served:
|
||||
if item.call.marker in aborted:
|
||||
assert item.status == 500, item.text
|
||||
assert '"type":"api_error"' in item.text and "marker-" not in item.text, item.text
|
||||
else:
|
||||
_assert_answered_with_its_own_marker(item)
|
||||
recovery: Final = _Call(stream=True, marker=uuid.uuid4().hex)
|
||||
(recovered,) = await _burst(str(gateway.client.base_url), gateway.key, model, (recovery,))
|
||||
_assert_answered_with_its_own_marker(recovered)
|
||||
_assert_every_request_carried_the_beta(wire.drain(), frozenset(call.marker for call in (*calls, recovery)))
|
||||
answered: Final = frozenset(f"msg_{call.marker}" for call in (*calls, recovery) if call.marker not in aborted)
|
||||
_spend_success_ids(model, answered)
|
||||
|
||||
|
||||
async def test_slow_streams_complete_with_the_beta_on_every_request(gateway: Gateway) -> None:
|
||||
calls: Final = _calls(10, stream=True)
|
||||
with (
|
||||
wire_server(lambda request: _streamed_reply(marker_of(request), pause=0.3)) as wire,
|
||||
gateway.scenario() as scenario,
|
||||
):
|
||||
model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY)
|
||||
served: Final = await _burst(str(gateway.client.base_url), gateway.key, model, calls)
|
||||
assert len(served) == 10
|
||||
for item in served:
|
||||
_assert_answered_with_its_own_marker(item)
|
||||
_assert_every_request_carried_the_beta(wire.drain(), frozenset(call.marker for call in calls))
|
||||
_spend_success_ids(model, frozenset(f"msg_{call.marker}" for call in calls))
|
||||
|
||||
|
||||
def _anthropic_deployment(wire: Wire) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"model_name": _CONFIG_MODEL,
|
||||
"litellm_params": {"model": f"anthropic/{ANTHROPIC_MODEL}", "api_base": wire.url, "api_key": ANTHROPIC_KEY},
|
||||
}
|
||||
|
||||
|
||||
def _foundry_deployment(wire: Wire) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"model_name": _FOUNDRY_CONFIG_MODEL,
|
||||
"litellm_params": {
|
||||
"model": "azure_ai/claude-sonnet-4-6",
|
||||
"api_base": wire.url,
|
||||
"api_key": "synthetic-foundry-key",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _chaos_config(tmp_path: Path, *deployments: dict[str, JsonValue]) -> Path:
|
||||
base: Final = _YAML_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
config: Final = {**base, "model_list": list(deployments)}
|
||||
path: Final = tmp_path / "anthropic-safeguards-chaos.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
return path
|
||||
|
||||
|
||||
def _open_upstream_connections(pid: int, upstream: str) -> int:
|
||||
port: Final = urlsplit(upstream).port
|
||||
return sum(
|
||||
1
|
||||
for connection in psutil.Process(pid).net_connections(kind="tcp")
|
||||
if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Needs OwnedProxy.replacing_worker, which this stable line's integration harness does not have")
|
||||
@pytest.mark.timeout(180)
|
||||
async def test_worker_sigkill_mid_burst_leaves_the_sibling_sending_the_beta(gateway: Gateway, tmp_path: Path) -> None:
|
||||
calls: Final = _calls(20, stream=False)
|
||||
release: Final = threading.Event()
|
||||
held_markers: Final[SimpleQueue[str]] = SimpleQueue()
|
||||
|
||||
def held(request: Request) -> Reply:
|
||||
if (request.method, request.target) == ("GET", "/v1/models"):
|
||||
return Reply(body=_MODEL_LIST)
|
||||
held_markers.put(marker_of(request))
|
||||
assert release.wait(timeout=60), "The burst was never released"
|
||||
return anthropic_peer(request)
|
||||
|
||||
with wire_server(held) as wire:
|
||||
path: Final = _chaos_config(tmp_path, _anthropic_deployment(wire))
|
||||
with owned_proxy_process(gateway, tmp_path, {}, config=path, workers=2) as owned:
|
||||
candidate: Final = owned.gateway
|
||||
workers: Final = eventually(
|
||||
lambda: tuple(int(found.group(1)) for found in _STARTED_WORKER.finditer(owned.log.read_text())),
|
||||
lambda pids: len(pids) == 2,
|
||||
seconds=30,
|
||||
)
|
||||
burst: Final = asyncio.create_task(
|
||||
_burst(
|
||||
str(candidate.client.base_url), candidate.key, _CONFIG_MODEL, calls, tolerate_transport_errors=True
|
||||
)
|
||||
)
|
||||
await asyncio.to_thread(eventually, held_markers.qsize, lambda size: size == 20, 60)
|
||||
held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, wire.url) for pid in workers})
|
||||
assert sum(held_by.values()) == 20, held_by
|
||||
victim_pid, survivor_pid = sorted(workers, key=held_by.__getitem__)
|
||||
with owned.replacing_worker(victim_pid) as kill:
|
||||
kill.freeze()
|
||||
await asyncio.to_thread(kill)
|
||||
release.set()
|
||||
served: Final = await burst
|
||||
assert held_by[survivor_pid] >= 10, held_by
|
||||
assert len(served) == held_by[survivor_pid], (held_by, len(served))
|
||||
for item in served:
|
||||
_assert_answered_with_its_own_marker(item)
|
||||
follow_up: Final = _Call(stream=False, marker=uuid.uuid4().hex)
|
||||
(answered,) = await _burst(str(candidate.client.base_url), candidate.key, _CONFIG_MODEL, (follow_up,))
|
||||
_assert_answered_with_its_own_marker(answered)
|
||||
received: Final = wire.drain()
|
||||
posts: Final = tuple(request for request in received if request.method == "POST")
|
||||
probes: Final[list[tuple[str, str]]] = [
|
||||
(request.method, request.target) for request in received if request.method != "POST"
|
||||
]
|
||||
assert set(probes) <= {("GET", "/v1/models")}, probes
|
||||
_assert_every_request_carried_the_beta(posts, frozenset(call.marker for call in (*calls, follow_up)))
|
||||
|
||||
|
||||
def _beta_map_gets(received: tuple[Request, ...]) -> int:
|
||||
return sum(1 for request in received if (request.method, request.target) == ("GET", "/beta.json"))
|
||||
|
||||
|
||||
async def _chat(base_url: str, key: str, marker: str) -> httpx.Response:
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=60, trust_env=False) as client:
|
||||
return await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": _CONFIG_MODEL,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": f"Question marker-{marker}"}],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
|
||||
|
||||
async def _answered_while_the_beta_map_is_held(
|
||||
base_url: str, key: str, guarded: tuple[_Call, ...], chat_markers: tuple[str, ...]
|
||||
) -> None:
|
||||
served: Final = await _burst(base_url, key, _CONFIG_MODEL, guarded)
|
||||
chats: Final = await asyncio.gather(*(_chat(base_url, key, marker) for marker in chat_markers))
|
||||
for item in served:
|
||||
_assert_answered_with_its_own_marker(item)
|
||||
for marker, chat in zip(chat_markers, chats, strict=True):
|
||||
assert chat.status_code == 200, chat.text
|
||||
assert f"marker-{marker}" in chat.text, chat.text
|
||||
|
||||
|
||||
@pytest.mark.timeout(240)
|
||||
async def test_cold_workers_fetch_the_beta_map_only_for_filtered_safeguards_requests_off_the_shared_threads(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
beta_map: Final = Path("litellm/anthropic_beta_headers_config.json").read_bytes()
|
||||
release: Final = threading.Event()
|
||||
|
||||
def held_beta_map(_: Request) -> Reply:
|
||||
assert release.wait(timeout=180), "The beta map was never released"
|
||||
return Reply(body=beta_map)
|
||||
|
||||
def upstream(request: Request) -> Reply:
|
||||
if (request.method, request.target) == ("GET", "/v1/models"):
|
||||
return Reply(body=_MODEL_LIST)
|
||||
return anthropic_peer(request)
|
||||
|
||||
plain: Final = _calls(8, stream=False, safeguards=False)
|
||||
plain_foundry: Final = _calls(8, stream=False, safeguards=False)
|
||||
direct: Final = _calls(8, stream=False)
|
||||
foundry: Final = _calls(80, stream=False)
|
||||
direct_while_held: Final = _calls(8, stream=False)
|
||||
chat_markers: Final = tuple(uuid.uuid4().hex for _ in range(4))
|
||||
with wire_server(held_beta_map) as beta_map_wire, wire_server(upstream) as wire:
|
||||
path: Final = _chaos_config(tmp_path, _anthropic_deployment(wire), _foundry_deployment(wire))
|
||||
with owned_proxy_process(
|
||||
gateway,
|
||||
tmp_path,
|
||||
{"LITELLM_ANTHROPIC_BETA_HEADERS_URL": f"{beta_map_wire.url}/beta.json"},
|
||||
remove_environment=("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS",),
|
||||
config=path,
|
||||
workers=2,
|
||||
) as owned:
|
||||
base_url: Final = str(owned.gateway.client.base_url)
|
||||
key: Final = owned.gateway.key
|
||||
cold: Final = (
|
||||
*await _burst(base_url, key, _CONFIG_MODEL, plain),
|
||||
*await _burst(base_url, key, _FOUNDRY_CONFIG_MODEL, plain_foundry),
|
||||
*await _burst(base_url, key, _CONFIG_MODEL, direct),
|
||||
)
|
||||
for served_cold in cold:
|
||||
_assert_answered_with_its_own_marker(served_cold)
|
||||
assert beta_map_wire.received.qsize() == 0, "a request that never filters betas fetched the beta map"
|
||||
|
||||
held: Final = asyncio.create_task(_burst(base_url, key, _FOUNDRY_CONFIG_MODEL, foundry))
|
||||
try:
|
||||
await asyncio.to_thread(eventually, beta_map_wire.received.qsize, lambda size: size >= 1, 60)
|
||||
await asyncio.wait_for(
|
||||
_answered_while_the_beta_map_is_held(base_url, key, direct_while_held, chat_markers), timeout=45
|
||||
)
|
||||
assert not held.done(), "Foundry safeguards requests were answered before the beta map loaded"
|
||||
finally:
|
||||
release.set()
|
||||
for served_foundry in await held:
|
||||
_assert_answered_with_its_own_marker(served_foundry)
|
||||
|
||||
received: Final = tuple(request for request in wire.drain() if request.method == "POST")
|
||||
beta_by_marker: Final = MappingProxyType(
|
||||
{marker_of(request): request.headers.get("anthropic-beta") for request in received}
|
||||
)
|
||||
assert {beta_by_marker[call.marker] for call in (*direct, *direct_while_held, *foundry)} == {BETA}, (
|
||||
beta_by_marker
|
||||
)
|
||||
assert {beta_by_marker[call.marker] for call in (*plain, *plain_foundry)} == {None}, beta_by_marker
|
||||
fetches: Final = _beta_map_gets(beta_map_wire.drain())
|
||||
assert 1 <= fetches <= 2, fetches
|
||||
|
|
@ -0,0 +1,809 @@
|
|||
import itertools
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import owned_proxy
|
||||
from integration._support.vertex import service_account_json
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
BETA: Final = "dangerous-tool-use-2026-09-03"
|
||||
SAFEGUARDS: Final[list[JsonValue]] = [
|
||||
{"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}}
|
||||
]
|
||||
ANTHROPIC_MODEL: Final = "claude-sonnet-4-5-20250929"
|
||||
ANTHROPIC_KEY: Final = "synthetic-anthropic-key"
|
||||
_CLAUDE_CODE_BETAS: Final = (
|
||||
"claude-code-20250219",
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
"context-management-2025-06-27",
|
||||
)
|
||||
_CLIENT_BETAS: Final = ",".join(_CLAUDE_CODE_BETAS)
|
||||
_CLIENT_BETAS_WITH_DANGEROUS: Final = ",".join((*_CLAUDE_CODE_BETAS, BETA))
|
||||
_MERGED_BETAS: Final = ",".join(sorted((*_CLAUDE_CODE_BETAS, BETA)))
|
||||
_FOUNDRY_MODEL: Final = "azure_ai/claude-sonnet-4-6"
|
||||
_FOUNDRY_KEY: Final = "synthetic-foundry-key"
|
||||
_FOUNDRY_TARGET: Final = "/anthropic/v1/messages"
|
||||
_FOUNDRY_ARRAY_ERROR: Final = "safeguards: Input should be a valid array"
|
||||
_FOUNDRY_EXTRA_INPUT_ERROR: Final = "safeguards: Extra inputs are not permitted"
|
||||
_ANTHROPIC_VERSION: Final = MappingProxyType({"anthropic-version": "2023-06-01"})
|
||||
_MESSAGES_KEYS: Final = frozenset({"max_tokens", "messages", "model", "stream"})
|
||||
_RESPONSES_KEYS: Final = frozenset({"include", "input", "max_output_tokens", "model"})
|
||||
_SDK_EXTRA: Final[dict[str, JsonValue]] = {"safeguards": SAFEGUARDS, "cache": {"no-cache": True}, "num_retries": 0}
|
||||
_MARKER: Final = re.compile(r"marker-([0-9a-f]{32})")
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Omitted:
|
||||
pass
|
||||
|
||||
|
||||
OMITTED: Final = _Omitted()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Observed:
|
||||
method: str
|
||||
target: str
|
||||
marker: str
|
||||
headers: Mapping[str, str]
|
||||
body: Mapping[str, JsonValue]
|
||||
|
||||
@property
|
||||
def beta(self) -> str | None:
|
||||
return self.headers.get("anthropic-beta")
|
||||
|
||||
@property
|
||||
def keys(self) -> frozenset[str]:
|
||||
return frozenset(self.body)
|
||||
|
||||
@property
|
||||
def header_names(self) -> frozenset[str]:
|
||||
return frozenset(self.headers)
|
||||
|
||||
|
||||
def marker_of(request: Request) -> str:
|
||||
found: Final = _MARKER.search(request.body.decode())
|
||||
assert found is not None, request.body
|
||||
return found.group(1)
|
||||
|
||||
|
||||
def _message(marker: str) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"id": f"msg_{marker}",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": ANTHROPIC_MODEL,
|
||||
"content": [{"type": "text", "text": f"answer marker-{marker}"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 12, "output_tokens": 5},
|
||||
}
|
||||
|
||||
|
||||
def stream_frames(marker: str) -> tuple[bytes, ...]:
|
||||
opening: Final[dict[str, JsonValue]] = {
|
||||
**_message(marker),
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"usage": {"input_tokens": 12, "output_tokens": 0},
|
||||
}
|
||||
events: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = (
|
||||
("message_start", {"message": opening}),
|
||||
("content_block_start", {"index": 0, "content_block": {"type": "text", "text": ""}}),
|
||||
("content_block_delta", {"index": 0, "delta": {"type": "text_delta", "text": f"answer marker-{marker}"}}),
|
||||
("content_block_stop", {"index": 0}),
|
||||
("message_delta", {"delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 5}}),
|
||||
("message_stop", {}),
|
||||
)
|
||||
return tuple(
|
||||
f"event: {kind}\ndata: {json.dumps({'type': kind, **payload})}\n\n".encode() for kind, payload in events
|
||||
)
|
||||
|
||||
|
||||
def anthropic_peer(request: Request) -> Reply:
|
||||
marker: Final = marker_of(request)
|
||||
if _JSON_OBJECT.validate_json(request.body).get("stream") is True:
|
||||
return Reply(content_type="text/event-stream", chunks=stream_frames(marker))
|
||||
return Reply(body=json.dumps(_message(marker)).encode())
|
||||
|
||||
|
||||
def _responses_peer(request: Request) -> Reply:
|
||||
marker: Final = marker_of(request)
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": f"resp_{marker}",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"status": "completed",
|
||||
"model": "gpt-4o-mini",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": f"msg_{marker}",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": f"answer marker-{marker}", "annotations": []}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 12, "output_tokens": 5, "total_tokens": 17},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
|
||||
def _error_peer(status: int, kind: str, message: str) -> Callable[[Request], Reply]:
|
||||
body: Final = json.dumps({"type": "error", "error": {"type": kind, "message": message}}).encode()
|
||||
return lambda request: Reply(status=status, body=body)
|
||||
|
||||
|
||||
def messages_body(
|
||||
model: str,
|
||||
marker: str,
|
||||
*,
|
||||
safeguards: JsonValue | _Omitted = SAFEGUARDS,
|
||||
stream: bool = False,
|
||||
no_cache: bool = True,
|
||||
) -> dict[str, JsonValue]:
|
||||
base: Final[dict[str, JsonValue]] = {
|
||||
"model": model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": f"Question marker-{marker}"}],
|
||||
"num_retries": 0,
|
||||
**({"stream": True} if stream else {}),
|
||||
**({"cache": {"no-cache": True}} if no_cache else {}),
|
||||
}
|
||||
if isinstance(safeguards, _Omitted):
|
||||
return base
|
||||
return {**base, "safeguards": safeguards}
|
||||
|
||||
|
||||
def _observe(request: Request) -> _Observed:
|
||||
return _Observed(
|
||||
request.method, request.target, marker_of(request), request.headers, _JSON_OBJECT.validate_json(request.body)
|
||||
)
|
||||
|
||||
|
||||
def _only(wire: Wire) -> _Observed:
|
||||
(request,) = wire.drain()
|
||||
return _observe(request)
|
||||
|
||||
|
||||
def _post(gateway: Gateway, body: Mapping[str, JsonValue], *, beta: str | None = None) -> httpx.Response:
|
||||
headers: Final = {**_ANTHROPIC_VERSION, **({"anthropic-beta": beta} if beta else {})}
|
||||
return gateway.request("POST", "/v1/messages", body, headers=headers)
|
||||
|
||||
|
||||
def _assert_answered(response: httpx.Response, marker: str) -> None:
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["id"] == f"msg_{marker}", response.text
|
||||
|
||||
|
||||
def _assert_streamed(response: httpx.Response, marker: str) -> None:
|
||||
assert response.status_code == 200, response.text
|
||||
assert f"msg_{marker}" in response.text and f"answer marker-{marker}" in response.text, response.text
|
||||
assert "event: message_stop" in response.text, response.text
|
||||
|
||||
|
||||
def _spend_row(identity: str) -> None:
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows('SELECT request_id, status FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (identity,)),
|
||||
lambda found: len(found) == 1,
|
||||
seconds=70,
|
||||
)
|
||||
assert rows[0]["status"] == "success", rows
|
||||
|
||||
|
||||
def _cache_hit_spend_row(identity: str) -> None:
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT request_id, status, cache_hit FROM "LiteLLM_SpendLogs" WHERE request_id LIKE %s',
|
||||
(f"{identity}\\_cache\\_hit%",),
|
||||
),
|
||||
lambda found: len(found) == 1,
|
||||
seconds=70,
|
||||
)
|
||||
assert (rows[0]["status"], str(rows[0]["cache_hit"])) == ("success", "True"), rows
|
||||
|
||||
|
||||
def _responses_spend_row(model: str) -> None:
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT request_id, status FROM "LiteLLM_SpendLogs" WHERE model_group = %s AND call_type = %s',
|
||||
(model, "aresponses"),
|
||||
),
|
||||
lambda found: len(found) == 1,
|
||||
seconds=70,
|
||||
)
|
||||
assert rows[0]["status"] == "success", rows
|
||||
|
||||
|
||||
def _expect_messages(
|
||||
observed: _Observed,
|
||||
*,
|
||||
target: str,
|
||||
auth: tuple[str, str] | None,
|
||||
beta: str | None,
|
||||
safeguards: JsonValue | _Omitted = SAFEGUARDS,
|
||||
keys: frozenset[str] = _MESSAGES_KEYS,
|
||||
) -> None:
|
||||
assert (observed.method, observed.target) == ("POST", target)
|
||||
if auth is not None:
|
||||
credential_matches: Final = observed.headers.get(auth[0]) == auth[1]
|
||||
assert credential_matches, f"{auth[0]} differs from the deployment credential; headers {observed.header_names}"
|
||||
assert observed.beta == beta, observed.header_names
|
||||
if isinstance(safeguards, _Omitted):
|
||||
assert observed.keys == keys
|
||||
return
|
||||
assert observed.keys == keys | {"safeguards"}
|
||||
assert observed.body["safeguards"] == safeguards
|
||||
|
||||
|
||||
def _expect_anthropic_version(observed: _Observed) -> None:
|
||||
assert observed.headers.get("anthropic-version") == "2023-06-01", observed.header_names
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ShapeCase:
|
||||
sent: JsonValue | _Omitted
|
||||
forwarded: JsonValue | _Omitted
|
||||
client_beta: str | None
|
||||
expected_beta: str | None
|
||||
stream: bool = False
|
||||
|
||||
|
||||
_ANTHROPIC_SHAPES: Final = MappingProxyType(
|
||||
{
|
||||
"D1_safeguards_without_client_header": _ShapeCase(SAFEGUARDS, SAFEGUARDS, None, BETA),
|
||||
"D2_safeguards_with_claude_code_header": _ShapeCase(
|
||||
SAFEGUARDS, SAFEGUARDS, _CLIENT_BETAS_WITH_DANGEROUS, _MERGED_BETAS
|
||||
),
|
||||
"D3_safeguards_with_claude_code_header_lacking_the_beta": _ShapeCase(
|
||||
SAFEGUARDS, SAFEGUARDS, _CLIENT_BETAS, _MERGED_BETAS
|
||||
),
|
||||
"D4_empty_safeguards": _ShapeCase([], [], None, BETA),
|
||||
"D5_null_safeguards": _ShapeCase(None, OMITTED, None, None),
|
||||
"D6_missing_safeguards": _ShapeCase(OMITTED, OMITTED, None, None),
|
||||
}
|
||||
)
|
||||
_FOUNDRY_SHAPES: Final = MappingProxyType(
|
||||
{
|
||||
"D9_streamed_safeguards_with_claude_code_header": _ShapeCase(
|
||||
SAFEGUARDS, SAFEGUARDS, _CLIENT_BETAS_WITH_DANGEROUS, BETA, stream=True
|
||||
),
|
||||
"D11_empty_safeguards": _ShapeCase([], [], None, BETA),
|
||||
"D12_null_safeguards": _ShapeCase(None, OMITTED, None, None),
|
||||
"D13_missing_safeguards_with_claude_code_header_lacking_the_beta": _ShapeCase(
|
||||
OMITTED, OMITTED, _CLIENT_BETAS, None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row", tuple(_ANTHROPIC_SHAPES))
|
||||
def test_anthropic_messages_derive_the_dangerous_tool_use_beta_from_safeguards(gateway: Gateway, row: str) -> None:
|
||||
case: Final = _ANTHROPIC_SHAPES[row]
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY)
|
||||
response: Final = _post(gateway, messages_body(model, marker, safeguards=case.sent), beta=case.client_beta)
|
||||
_assert_answered(response, marker)
|
||||
observed: Final = _only(wire)
|
||||
_expect_messages(
|
||||
observed,
|
||||
target="/v1/messages",
|
||||
auth=("x-api-key", ANTHROPIC_KEY),
|
||||
beta=case.expected_beta,
|
||||
safeguards=case.forwarded,
|
||||
)
|
||||
_expect_anthropic_version(observed)
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class _DropCase:
|
||||
deployment: Callable[[Gateway], Mapping[str, JsonValue]]
|
||||
target: str
|
||||
auth: tuple[str, str] | None
|
||||
keys: frozenset[str] = _MESSAGES_KEYS
|
||||
|
||||
|
||||
_VERTEX_PROJECT: Final = "scripted-project"
|
||||
_VERTEX_LOCATION: Final = "us-east5"
|
||||
_VERTEX_MODEL: Final = "claude-opus-5-5"
|
||||
|
||||
_DROP_CASES: Final = MappingProxyType(
|
||||
{
|
||||
"anthropic": _DropCase(
|
||||
deployment=lambda _: {"model": f"anthropic/{ANTHROPIC_MODEL}", "api_key": ANTHROPIC_KEY},
|
||||
target="/v1/messages",
|
||||
auth=("x-api-key", ANTHROPIC_KEY),
|
||||
),
|
||||
"azure_ai": _DropCase(
|
||||
deployment=lambda _: {"model": _FOUNDRY_MODEL, "api_key": _FOUNDRY_KEY},
|
||||
target=_FOUNDRY_TARGET,
|
||||
auth=("x-api-key", _FOUNDRY_KEY),
|
||||
),
|
||||
"bedrock_claude_platform": _DropCase(
|
||||
deployment=lambda _: {
|
||||
"model": f"bedrock/claude_platform/{ANTHROPIC_MODEL}",
|
||||
"api_key": "synthetic-platform-key",
|
||||
"workspace_id": "ws-synthetic",
|
||||
"aws_region_name": "us-east-1",
|
||||
},
|
||||
target="/v1/messages",
|
||||
auth=("x-api-key", "synthetic-platform-key"),
|
||||
),
|
||||
"vertex_ai": _DropCase(
|
||||
deployment=lambda gateway: {
|
||||
"model": f"vertex_ai/{_VERTEX_MODEL}",
|
||||
"api_key": None,
|
||||
"vertex_project": _VERTEX_PROJECT,
|
||||
"vertex_location": _VERTEX_LOCATION,
|
||||
"vertex_credentials": service_account_json(_VERTEX_PROJECT, gateway.upstream_url.rstrip("/")),
|
||||
},
|
||||
target=(
|
||||
f"/v1/projects/{_VERTEX_PROJECT}/locations/{_VERTEX_LOCATION}"
|
||||
f"/publishers/anthropic/models/{_VERTEX_MODEL}:rawPredict"
|
||||
),
|
||||
auth=None,
|
||||
keys=frozenset({"anthropic_version", "max_tokens", "messages", "stream"}),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row", tuple(_DROP_CASES))
|
||||
def test_a_deployment_dropping_safeguards_sends_neither_the_field_nor_the_beta(gateway: Gateway, row: str) -> None:
|
||||
case: Final = _DROP_CASES[row]
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
api_base=wire.url, additional_drop_params=["safeguards"], **case.deployment(gateway)
|
||||
)
|
||||
response: Final = _post(gateway, messages_body(model, marker))
|
||||
_assert_answered(response, marker)
|
||||
_expect_messages(_only(wire), target=case.target, auth=case.auth, beta=None, safeguards=OMITTED, keys=case.keys)
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
def test_a_deployment_dropping_context_management_sends_neither_the_field_nor_its_beta(gateway: Gateway) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"anthropic/{ANTHROPIC_MODEL}",
|
||||
api_base=wire.url,
|
||||
api_key=ANTHROPIC_KEY,
|
||||
additional_drop_params=["context_management"],
|
||||
)
|
||||
body: Final = {
|
||||
**messages_body(model, marker, safeguards=OMITTED),
|
||||
"context_management": {"edits": [{"type": "clear_tool_uses_20250919"}]},
|
||||
}
|
||||
response: Final = _post(gateway, body)
|
||||
_assert_answered(response, marker)
|
||||
_expect_messages(
|
||||
_only(wire), target="/v1/messages", auth=("x-api-key", ANTHROPIC_KEY), beta=None, safeguards=OMITTED
|
||||
)
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row", tuple(_FOUNDRY_SHAPES))
|
||||
def test_azure_ai_messages_derive_the_beta_instead_of_forwarding_the_client_header(gateway: Gateway, row: str) -> None:
|
||||
case: Final = _FOUNDRY_SHAPES[row]
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY)
|
||||
response: Final = _post(
|
||||
gateway, messages_body(model, marker, safeguards=case.sent, stream=case.stream), beta=case.client_beta
|
||||
)
|
||||
if case.stream:
|
||||
_assert_streamed(response, marker)
|
||||
else:
|
||||
_assert_answered(response, marker)
|
||||
observed: Final = _only(wire)
|
||||
_expect_messages(
|
||||
observed,
|
||||
target=_FOUNDRY_TARGET,
|
||||
auth=("x-api-key", _FOUNDRY_KEY),
|
||||
beta=case.expected_beta,
|
||||
safeguards=case.forwarded,
|
||||
)
|
||||
_expect_anthropic_version(observed)
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
def test_anthropic_sdk_sync_safeguards_derive_the_beta(gateway: Gateway) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY)
|
||||
client: Final = anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0)
|
||||
reply: Final = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=16,
|
||||
messages=[{"role": "user", "content": f"Question marker-{marker}"}],
|
||||
extra_body=_SDK_EXTRA,
|
||||
)
|
||||
assert reply.id == f"msg_{marker}", reply
|
||||
_expect_messages(_only(wire), target="/v1/messages", auth=("x-api-key", ANTHROPIC_KEY), beta=BETA)
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
async def test_anthropic_sdk_async_stream_safeguards_derive_the_beta_and_log_spend(gateway: Gateway) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY)
|
||||
client: Final = anthropic.AsyncAnthropic(
|
||||
base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0
|
||||
)
|
||||
async with client.messages.with_streaming_response.create(
|
||||
model=model,
|
||||
max_tokens=16,
|
||||
messages=[{"role": "user", "content": f"Question marker-{marker}"}],
|
||||
stream=True,
|
||||
extra_body=_SDK_EXTRA,
|
||||
) as streamed:
|
||||
pieces: Final = [
|
||||
event.delta.text
|
||||
async for event in await streamed.parse()
|
||||
if event.type == "content_block_delta" and event.delta.type == "text_delta"
|
||||
]
|
||||
assert "".join(pieces) == f"answer marker-{marker}", pieces
|
||||
_expect_messages(_only(wire), target="/v1/messages", auth=("x-api-key", ANTHROPIC_KEY), beta=BETA)
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
def test_azure_ai_sdk_sync_safeguards_derive_the_beta(gateway: Gateway) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY)
|
||||
client: Final = anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0)
|
||||
reply: Final = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=16,
|
||||
messages=[{"role": "user", "content": f"Question marker-{marker}"}],
|
||||
extra_body=_SDK_EXTRA,
|
||||
)
|
||||
assert reply.id == f"msg_{marker}", reply
|
||||
_expect_messages(_only(wire), target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA)
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class _ProviderCase:
|
||||
parameters: Mapping[str, JsonValue]
|
||||
auth: tuple[str, str] | None
|
||||
expected_beta: str | None
|
||||
target: str = "/v1/messages"
|
||||
api_base_suffix: str = ""
|
||||
model_info: Mapping[str, JsonValue] | None = None
|
||||
client_beta: str | None = None
|
||||
keys: frozenset[str] = _MESSAGES_KEYS
|
||||
extra_headers: Mapping[str, str] = MappingProxyType({})
|
||||
body_beta: list[str] | None = None
|
||||
|
||||
|
||||
_PROVIDERS: Final = MappingProxyType(
|
||||
{
|
||||
"D14_deepseek_with_claude_code_header": _ProviderCase(
|
||||
parameters={"model": "deepseek/deepseek-chat", "api_key": "synthetic-deepseek-key"},
|
||||
client_beta=_CLIENT_BETAS_WITH_DANGEROUS,
|
||||
target="/anthropic/v1/messages",
|
||||
auth=("x-api-key", "synthetic-deepseek-key"),
|
||||
expected_beta=None,
|
||||
),
|
||||
"D15_tencent_with_claude_code_header": _ProviderCase(
|
||||
parameters={"model": "tencent/deepseek-v4-flash", "api_key": "synthetic-tencent-key"},
|
||||
client_beta=_CLIENT_BETAS_WITH_DANGEROUS,
|
||||
auth=("x-api-key", "synthetic-tencent-key"),
|
||||
expected_beta=None,
|
||||
),
|
||||
"D16_minimax_with_claude_code_header": _ProviderCase(
|
||||
parameters={"model": "minimax/MiniMax-M2", "api_key": "synthetic-minimax-key"},
|
||||
client_beta=_CLIENT_BETAS_WITH_DANGEROUS,
|
||||
auth=("x-api-key", "synthetic-minimax-key"),
|
||||
expected_beta=None,
|
||||
),
|
||||
"D17_openai_compatible_native_passthrough": _ProviderCase(
|
||||
parameters={"model": "openai/claude-sonnet-4-5", "api_key": "synthetic-openai-like-key"},
|
||||
api_base_suffix="/v1",
|
||||
model_info={"supported_endpoints": ["/v1/messages"]},
|
||||
auth=("authorization", "Bearer synthetic-openai-like-key"),
|
||||
expected_beta=None,
|
||||
),
|
||||
"D18_sail_with_claude_code_header": _ProviderCase(
|
||||
parameters={"model": "sail/sail-claude", "api_key": "synthetic-sail-key"},
|
||||
client_beta=_CLIENT_BETAS_WITH_DANGEROUS,
|
||||
auth=("authorization", "Bearer synthetic-sail-key"),
|
||||
expected_beta=None,
|
||||
),
|
||||
"D19_edenai": _ProviderCase(
|
||||
parameters={"model": "edenai/claude-sonnet-4-5", "api_key": "synthetic-edenai-key"},
|
||||
auth=("authorization", "Bearer synthetic-edenai-key"),
|
||||
expected_beta=None,
|
||||
),
|
||||
"D21_bedrock_claude_platform": _ProviderCase(
|
||||
parameters={
|
||||
"model": f"bedrock/claude_platform/{ANTHROPIC_MODEL}",
|
||||
"api_key": "synthetic-platform-key",
|
||||
"workspace_id": "ws-synthetic",
|
||||
"aws_region_name": "us-east-1",
|
||||
},
|
||||
auth=("x-api-key", "synthetic-platform-key"),
|
||||
expected_beta=BETA,
|
||||
extra_headers={"anthropic-workspace-id": "ws-synthetic"},
|
||||
),
|
||||
"D22_bedrock_claude_platform_with_claude_code_header": _ProviderCase(
|
||||
parameters={
|
||||
"model": f"bedrock/claude_platform/{ANTHROPIC_MODEL}",
|
||||
"api_key": "synthetic-platform-key",
|
||||
"workspace_id": "ws-synthetic",
|
||||
"aws_region_name": "us-east-1",
|
||||
},
|
||||
client_beta=_CLIENT_BETAS_WITH_DANGEROUS,
|
||||
auth=("x-api-key", "synthetic-platform-key"),
|
||||
expected_beta=_MERGED_BETAS,
|
||||
extra_headers={"anthropic-workspace-id": "ws-synthetic"},
|
||||
),
|
||||
"D23_bedrock_invoke": _ProviderCase(
|
||||
parameters={
|
||||
"model": "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"api_key": "synthetic-bedrock-bearer",
|
||||
"aws_region_name": "us-east-1",
|
||||
},
|
||||
target="/model/anthropic.claude-sonnet-4-5-20250929-v1:0/invoke",
|
||||
auth=("authorization", "Bearer synthetic-bedrock-bearer"),
|
||||
expected_beta=None,
|
||||
keys=frozenset({"anthropic_beta", "anthropic_version", "max_tokens", "messages"}),
|
||||
body_beta=[BETA],
|
||||
),
|
||||
"D24_bedrock_mantle": _ProviderCase(
|
||||
parameters={
|
||||
"model": "bedrock_mantle/anthropic.claude-sonnet-5-v1:0",
|
||||
"api_key": "synthetic-mantle-bearer",
|
||||
"aws_region_name": "us-east-1",
|
||||
},
|
||||
target="/anthropic/v1/messages",
|
||||
auth=("authorization", "Bearer synthetic-mantle-bearer"),
|
||||
expected_beta=BETA,
|
||||
keys=frozenset({"max_tokens", "messages", "model"}),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row", tuple(_PROVIDERS))
|
||||
def test_anthropic_compatible_providers_receive_the_beta_only_where_the_proxy_derives_it(
|
||||
gateway: Gateway, row: str
|
||||
) -> None:
|
||||
case: Final = _PROVIDERS[row]
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model_info=case.model_info, api_base=wire.url + case.api_base_suffix, **case.parameters
|
||||
)
|
||||
response: Final = _post(gateway, messages_body(model, marker), beta=case.client_beta)
|
||||
_assert_answered(response, marker)
|
||||
observed: Final = _only(wire)
|
||||
_expect_messages(observed, target=case.target, auth=case.auth, beta=case.expected_beta, keys=case.keys)
|
||||
for name, value in case.extra_headers.items():
|
||||
assert observed.headers.get(name) == value, observed.header_names
|
||||
assert observed.body.get("anthropic_beta") == case.body_beta, observed.body
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
def test_github_copilot_messages_forward_safeguards_without_deriving_the_beta(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
token_dir: Final = tmp_path / "copilot"
|
||||
token_dir.mkdir()
|
||||
with wire_server(anthropic_peer) as wire:
|
||||
(token_dir / "api-key.json").write_text(
|
||||
json.dumps({"token": "synthetic-copilot-token", "expires_at": 4102444800, "endpoints": {"api": wire.url}})
|
||||
)
|
||||
with (
|
||||
owned_proxy(gateway, tmp_path, {"GITHUB_COPILOT_TOKEN_DIR": str(token_dir)}) as owned,
|
||||
owned.scenario() as scenario,
|
||||
):
|
||||
model: Final = scenario.model(model="github_copilot/claude-sonnet-4-5", api_base=wire.url)
|
||||
response: Final = _post(owned, messages_body(model, marker), beta=_CLIENT_BETAS_WITH_DANGEROUS)
|
||||
_assert_answered(response, marker)
|
||||
observed: Final = _only(wire)
|
||||
_expect_messages(
|
||||
observed, target="/v1/messages", auth=("authorization", "Bearer synthetic-copilot-token"), beta=None
|
||||
)
|
||||
assert observed.headers.get("openai-intent") == "messages-proxy", observed.header_names
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
def test_non_native_deployments_bridge_to_the_responses_api_without_safeguards_or_the_beta(gateway: Gateway) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(_responses_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model="openai/gpt-4o-mini", api_base=wire.url + "/v1", api_key="synthetic-openai-key"
|
||||
)
|
||||
response: Final = _post(gateway, messages_body(model, marker), beta=_CLIENT_BETAS_WITH_DANGEROUS)
|
||||
assert response.status_code == 200 and f"answer marker-{marker}" in response.text, response.text
|
||||
observed: Final = _only(wire)
|
||||
assert (observed.method, observed.target) == ("POST", "/v1/responses")
|
||||
assert observed.headers.get("authorization") == "Bearer synthetic-openai-key", observed.header_names
|
||||
assert observed.beta is None, observed.header_names
|
||||
assert observed.keys == _RESPONSES_KEYS, observed.keys
|
||||
_spend_row(str(_JSON_OBJECT.validate_json(response.content)["id"]))
|
||||
|
||||
|
||||
def test_chat_completions_forward_safeguards_verbatim_without_deriving_the_beta(gateway: Gateway) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY)
|
||||
client: Final = openai.OpenAI(
|
||||
base_url=str(gateway.client.base_url).rstrip("/") + "/v1", api_key=gateway.key, max_retries=0
|
||||
)
|
||||
completion: Final = client.chat.completions.create(
|
||||
model=model, messages=[{"role": "user", "content": f"Question marker-{marker}"}], extra_body=_SDK_EXTRA
|
||||
)
|
||||
assert completion.choices[0].message.content == f"answer marker-{marker}", completion
|
||||
_expect_messages(
|
||||
_only(wire),
|
||||
target="/v1/messages",
|
||||
auth=("x-api-key", ANTHROPIC_KEY),
|
||||
beta=None,
|
||||
keys=frozenset({"max_tokens", "messages", "model"}),
|
||||
)
|
||||
_spend_row(completion.id)
|
||||
|
||||
|
||||
async def test_responses_stream_forwards_safeguards_verbatim_without_deriving_the_beta(gateway: Gateway) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY)
|
||||
client: Final = openai.AsyncOpenAI(
|
||||
base_url=str(gateway.client.base_url).rstrip("/") + "/v1", api_key=gateway.key, max_retries=0
|
||||
)
|
||||
async with client.responses.with_streaming_response.create(
|
||||
model=model, input=f"Question marker-{marker}", stream=True, extra_body=_SDK_EXTRA
|
||||
) as streamed:
|
||||
events: Final = [event async for event in await streamed.parse()]
|
||||
deltas: Final = [event.delta for event in events if event.type == "response.output_text.delta"]
|
||||
assert "".join(deltas) == f"answer marker-{marker}", events
|
||||
assert any(event.type == "response.completed" for event in events), events
|
||||
_expect_messages(_only(wire), target="/v1/messages", auth=("x-api-key", ANTHROPIC_KEY), beta=None)
|
||||
_responses_spend_row(model)
|
||||
|
||||
|
||||
_FOUNDRY_REJECTED_SHAPES: Final[Mapping[str, JsonValue]] = MappingProxyType(
|
||||
{"S1_int": 1, "S2_empty_string": "", "S3_five_kb_string": "x" * 5000}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row", tuple(_FOUNDRY_REJECTED_SHAPES))
|
||||
def test_foundry_rejecting_a_non_array_safeguards_value_reaches_the_caller_after_one_attempt(
|
||||
gateway: Gateway, row: str
|
||||
) -> None:
|
||||
value: Final = _FOUNDRY_REJECTED_SHAPES[row]
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with (
|
||||
wire_server(_error_peer(400, "invalid_request_error", _FOUNDRY_ARRAY_ERROR)) as wire,
|
||||
gateway.scenario() as scenario,
|
||||
):
|
||||
model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY)
|
||||
response: Final = _post(gateway, messages_body(model, marker, safeguards=value))
|
||||
assert response.status_code == 400 and _FOUNDRY_ARRAY_ERROR in response.text, response.text
|
||||
_expect_messages(
|
||||
_only(wire), target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA, safeguards=value
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_safeguards_keys_resolve_to_the_last_value(gateway: Gateway) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY)
|
||||
raw: Final = (
|
||||
f'{{"model": "{model}", "max_tokens": 16, "num_retries": 0, "cache": {{"no-cache": true}}, '
|
||||
f'"messages": [{{"role": "user", "content": "Question marker-{marker}"}}], '
|
||||
f'"safeguards": {json.dumps(SAFEGUARDS)}, "safeguards": null}}'
|
||||
)
|
||||
response: Final = gateway.client.post(
|
||||
"/v1/messages",
|
||||
content=raw.encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {gateway.key}",
|
||||
"content-type": "application/json",
|
||||
**_ANTHROPIC_VERSION,
|
||||
},
|
||||
)
|
||||
_assert_answered(response, marker)
|
||||
_expect_messages(
|
||||
_only(wire), target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=None, safeguards=OMITTED
|
||||
)
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
def test_the_same_safeguards_sent_twice_are_two_upstream_calls_with_two_spend_rows(gateway: Gateway) -> None:
|
||||
markers: Final = (uuid.uuid4().hex, uuid.uuid4().hex)
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY)
|
||||
responses: Final = tuple(_post(gateway, messages_body(model, marker)) for marker in markers)
|
||||
for marker, response in zip(markers, responses, strict=True):
|
||||
_assert_answered(response, marker)
|
||||
received: Final = tuple(_observe(request) for request in wire.drain())
|
||||
assert len(received) == 2, received
|
||||
for observed in received:
|
||||
_expect_messages(observed, target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA)
|
||||
assert sorted(item.marker for item in received) == sorted(markers)
|
||||
for marker in markers:
|
||||
_spend_row(f"msg_{marker}")
|
||||
|
||||
|
||||
_UPSTREAM_ERRORS: Final = MappingProxyType(
|
||||
{
|
||||
"S6_unauthorized_deployment": (401, "authentication_error", "invalid x-api-key"),
|
||||
"S7_foundry_extra_inputs_rejection": (400, "invalid_request_error", _FOUNDRY_EXTRA_INPUT_ERROR),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row", tuple(_UPSTREAM_ERRORS))
|
||||
def test_upstream_errors_reach_the_caller_with_the_beta_sent_once(gateway: Gateway, row: str) -> None:
|
||||
status, kind, message = _UPSTREAM_ERRORS[row]
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(_error_peer(status, kind, message)) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY)
|
||||
response: Final = _post(gateway, messages_body(model, marker))
|
||||
assert response.status_code == status and message in response.text, response.text
|
||||
_expect_messages(_only(wire), target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA)
|
||||
|
||||
|
||||
def _post_noting_whether_upstream_was_called(
|
||||
gateway: Gateway, body: Mapping[str, JsonValue], wire: Wire
|
||||
) -> tuple[httpx.Response, bool]:
|
||||
before: Final = wire.received.qsize()
|
||||
response: Final = _post(gateway, body)
|
||||
assert response.status_code == 200, response.text
|
||||
return response, wire.received.qsize() > before
|
||||
|
||||
|
||||
def _until_served_from_cache(gateway: Gateway, body: Mapping[str, JsonValue], wire: Wire) -> tuple[httpx.Response, int]:
|
||||
deadline: Final = time.monotonic() + 20
|
||||
for attempt in itertools.count(1):
|
||||
response, called_upstream = _post_noting_whether_upstream_was_called(gateway, body, wire)
|
||||
if not called_upstream:
|
||||
return response, attempt
|
||||
assert time.monotonic() < deadline, "the response cache never served the repeated request"
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def test_the_response_cache_serves_the_repeated_request_without_an_upstream_call(gateway: Gateway) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY)
|
||||
body: Final = messages_body(model, marker, no_cache=False)
|
||||
first: Final = _post(gateway, body)
|
||||
_assert_answered(first, marker)
|
||||
_expect_messages(_only(wire), target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA)
|
||||
_spend_row(f"msg_{marker}")
|
||||
hit, attempts = _until_served_from_cache(gateway, body, wire)
|
||||
assert hit.json()["id"] == first.json()["id"], hit.text
|
||||
assert hit.json()["content"] == first.json()["content"], hit.text
|
||||
_cache_hit_spend_row(f"msg_{marker}")
|
||||
misses: Final = tuple(_observe(request) for request in wire.drain())
|
||||
assert len(misses) == attempts - 1, (attempts, misses)
|
||||
for observed in misses:
|
||||
_expect_messages(observed, target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA)
|
||||
File diff suppressed because it is too large
Load diff
186
tests/integration/sdk/test_databricks_json_schema_refs_sdk.py
Normal file
186
tests/integration/sdk/test_databricks_json_schema_refs_sdk.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
||||
|
||||
import litellm
|
||||
|
||||
_QWEN_BACKEND: Final = "databricks-qwen35-122b-a10b"
|
||||
_CLAUDE_BACKEND: Final = "databricks-claude-haiku-4-5"
|
||||
_API_KEY: Final = "synthetic-databricks-key"
|
||||
_ORDER_JSON: Final = '{"buyer":{"name":"Ada"},"seller":{"name":"Bo"}}'
|
||||
_MESSAGES: Final[tuple[Mapping[str, str], ...]] = ({"role": "user", "content": "Return the order as JSON."},)
|
||||
|
||||
|
||||
class Person(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
name: str
|
||||
|
||||
|
||||
class Order(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
buyer: Person
|
||||
seller: Person
|
||||
|
||||
|
||||
class _StrictObject(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
additional_properties: bool | None = Field(default=None, alias="additionalProperties")
|
||||
|
||||
|
||||
class _OrderSchema(_StrictObject):
|
||||
defs: Mapping[str, _StrictObject] = Field(alias="$defs")
|
||||
|
||||
|
||||
class _JsonSchema(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
name: str
|
||||
strict: bool | None = None
|
||||
json_schema: dict[str, JsonValue] = Field(alias="schema")
|
||||
|
||||
|
||||
class _ResponseFormat(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
type: str
|
||||
json_schema: _JsonSchema
|
||||
|
||||
|
||||
class _Function(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
name: str
|
||||
parameters: dict[str, JsonValue]
|
||||
|
||||
|
||||
class _Tool(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
function: _Function
|
||||
|
||||
|
||||
class _Outbound(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
model: str
|
||||
response_format: _ResponseFormat | None = None
|
||||
tools: tuple[_Tool, ...] = ()
|
||||
|
||||
|
||||
class _CallerMessage(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
content: str
|
||||
|
||||
|
||||
class _CallerChoice(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
message: _CallerMessage
|
||||
|
||||
|
||||
class _CallerCompletion(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
choices: tuple[_CallerChoice, ...]
|
||||
|
||||
|
||||
def _completion(backend: str) -> Reply:
|
||||
claude: Final = "claude" in backend
|
||||
tool_call: Final[dict[str, JsonValue]] = {
|
||||
"id": "json-tool-call",
|
||||
"type": "function",
|
||||
"function": {"name": "json_tool_call", "arguments": _ORDER_JSON},
|
||||
}
|
||||
message: Final[dict[str, JsonValue]] = (
|
||||
{"role": "assistant", "content": None, "tool_calls": [tool_call]}
|
||||
if claude
|
||||
else {"role": "assistant", "content": _ORDER_JSON}
|
||||
)
|
||||
body: Final[dict[str, JsonValue]] = {
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": backend,
|
||||
"choices": [{"index": 0, "message": message, "finish_reason": "tool_calls" if claude else "stop"}],
|
||||
"usage": {"prompt_tokens": 9, "completion_tokens": 7, "total_tokens": 16},
|
||||
}
|
||||
return Reply(body=json.dumps(body).encode())
|
||||
|
||||
|
||||
def _refs(node: JsonValue) -> Iterator[str]:
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
yield from _refs(item)
|
||||
return
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
for key, value in node.items():
|
||||
if key == "$ref" and isinstance(value, str):
|
||||
yield value
|
||||
else:
|
||||
yield from _refs(value)
|
||||
|
||||
|
||||
def _sent_schema(backend: str, received: tuple[Request, ...]) -> dict[str, JsonValue]:
|
||||
assert [(request.method, request.target) for request in received] == [("POST", "/chat/completions")]
|
||||
outbound: Final = _Outbound.model_validate_json(received[0].body)
|
||||
assert outbound.model == backend
|
||||
if "claude" in backend:
|
||||
assert outbound.response_format is None
|
||||
assert [tool.function.name for tool in outbound.tools] == ["json_tool_call"]
|
||||
return outbound.tools[0].function.parameters
|
||||
assert outbound.response_format is not None
|
||||
assert (outbound.response_format.type, outbound.response_format.json_schema.name) == ("json_schema", "Order")
|
||||
assert outbound.response_format.json_schema.strict is True
|
||||
return outbound.response_format.json_schema.json_schema
|
||||
|
||||
|
||||
def _assert_strict_order_schema(schema: dict[str, JsonValue]) -> None:
|
||||
assert set(_refs(schema)) == {"#/$defs/Person"}, schema
|
||||
parsed: Final = _OrderSchema.model_validate(schema)
|
||||
assert (parsed.additional_properties, parsed.defs["Person"].additional_properties) == (False, False), schema
|
||||
|
||||
|
||||
def _caller_order(response: litellm.ModelResponse) -> Order:
|
||||
return Order.model_validate_json(
|
||||
_CallerCompletion.model_validate_json(response.model_dump_json()).choices[0].message.content
|
||||
)
|
||||
|
||||
|
||||
_BACKENDS: Final = (
|
||||
pytest.param(_QWEN_BACKEND, id="qwen"),
|
||||
pytest.param(_CLAUDE_BACKEND, id="claude"),
|
||||
)
|
||||
_EXPECTED_ORDER: Final = Order(buyer=Person(name="Ada"), seller=Person(name="Bo"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", _BACKENDS)
|
||||
def test_databricks_pydantic_response_format_sends_strict_defs_refs(backend: str) -> None:
|
||||
with wire_server(lambda _request: _completion(backend)) as wire:
|
||||
response: Final = litellm.completion(
|
||||
model=f"databricks/{backend}",
|
||||
api_base=wire.url,
|
||||
api_key=_API_KEY,
|
||||
messages=[dict(message) for message in _MESSAGES],
|
||||
response_format=Order,
|
||||
num_retries=0,
|
||||
)
|
||||
received: Final = wire.drain()
|
||||
_assert_strict_order_schema(_sent_schema(backend, received))
|
||||
assert isinstance(response, litellm.ModelResponse)
|
||||
assert _caller_order(response) == _EXPECTED_ORDER
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", _BACKENDS)
|
||||
async def test_databricks_pydantic_response_format_sends_strict_defs_refs_async(backend: str) -> None:
|
||||
with wire_server(lambda _request: _completion(backend)) as wire:
|
||||
response: Final = await litellm.acompletion(
|
||||
model=f"databricks/{backend}",
|
||||
api_base=wire.url,
|
||||
api_key=_API_KEY,
|
||||
messages=[dict(message) for message in _MESSAGES],
|
||||
response_format=Order,
|
||||
num_retries=0,
|
||||
)
|
||||
received: Final = wire.drain()
|
||||
_assert_strict_order_schema(_sent_schema(backend, received))
|
||||
assert isinstance(response, litellm.ModelResponse)
|
||||
assert _caller_order(response) == _EXPECTED_ORDER
|
||||
|
|
@ -17,6 +17,7 @@ deterministic stand-ins so the arithmetic under test is the only variable.
|
|||
import json
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
|
@ -2481,3 +2482,30 @@ async def test_flag_sends_every_vertex_row_down_the_native_path_when_a_model_is_
|
|||
|
||||
assert calls == []
|
||||
assert (result.successful_requests, result.failed_requests) == (0, 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", ("openai", "anthropic"))
|
||||
@pytest.mark.parametrize("envelope", (1, "invalid", [1], None, {}))
|
||||
@pytest.mark.parametrize("include_valid", (True, False))
|
||||
def test_invalid_batch_envelopes_preserve_recoverable_cost(
|
||||
provider: Literal["openai", "anthropic"], envelope: object, include_valid: bool
|
||||
) -> None:
|
||||
invalid: Final[dict[str, object]] = {"response" if provider == "openai" else "result": envelope}
|
||||
valid: Final[dict[str, object]] = (
|
||||
_success_row(model="batch-accounting-control", usage=_usage(100, 20))
|
||||
if provider == "openai"
|
||||
else {
|
||||
"result": {
|
||||
"type": "succeeded",
|
||||
"message": {"model": "batch-accounting-control", "usage": {"input_tokens": 100, "output_tokens": 20}},
|
||||
}
|
||||
}
|
||||
)
|
||||
entries: Final = (valid, invalid, valid) if include_valid else (invalid,)
|
||||
result: Final = bu._aggregate_batch_cost_usage_models(
|
||||
entries, provider, model_info={"input_cost_per_token_batches": 0.001, "output_cost_per_token_batches": 0.002}
|
||||
)
|
||||
assert result.cost == pytest.approx(0.28 if include_valid else 0)
|
||||
assert result.usage.prompt_tokens == (200 if include_valid else 0)
|
||||
assert result.usage.completion_tokens == (40 if include_valid else 0)
|
||||
assert (result.successful_requests, result.failed_requests) == (2 if include_valid else 0, 1)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ with deployment credentials, bypassing the managed files access-control hooks.
|
|||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Final
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -367,13 +369,14 @@ async def test_check_batch_cost_should_call_afile_content_directly_with_credenti
|
|||
# managed_files_obj.afile_content should NOT have been called
|
||||
mock_managed_files_hook.afile_content.assert_not_called()
|
||||
|
||||
# Verify the DB update writes batch_processed, status, and file_object
|
||||
mock_prisma.db.litellm_managedobjecttable.update.assert_called_once()
|
||||
update_call_kwargs = (
|
||||
mock_prisma.db.litellm_managedobjecttable.update.call_args.kwargs
|
||||
claim_data: Final = next(
|
||||
call.kwargs["data"]
|
||||
for call in mock_prisma.db.litellm_managedobjecttable.update_many.call_args_list
|
||||
if call.kwargs["where"].get("id") == mock_job.id
|
||||
)
|
||||
assert update_call_kwargs["data"]["batch_processed"] is True
|
||||
assert update_call_kwargs["data"]["status"] == "complete"
|
||||
assert (
|
||||
"file_object" in update_call_kwargs["data"]
|
||||
), "file_object must be written to DB so list_batches reads updated status"
|
||||
assert claim_data["batch_processed"] is True
|
||||
assert claim_data["status"] == "complete"
|
||||
persisted_batch: Final = json.loads(claim_data["file_object"])
|
||||
assert persisted_batch["status"] == "completed"
|
||||
assert persisted_batch["usage"]["prompt_tokens"] == 10
|
||||
assert persisted_batch["usage"]["completion_tokens"] == 5
|
||||
|
|
|
|||
|
|
@ -324,8 +324,6 @@ def test_reasoning_tokens_gemini(_local_model_cost_map):
|
|||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def test_image_tokens_with_custom_pricing():
|
||||
"""Test that image_tokens in completion are properly costed with output_cost_per_image_token."""
|
||||
from unittest.mock import patch
|
||||
|
|
@ -2299,10 +2297,6 @@ def test_vertex_image_generation_cost_falls_back_to_flat_image_pricing(_local_mo
|
|||
assert round(cost, 10) == round(expected_cost, 10)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_query_count_is_free_without_a_per_query_price(_local_model_cost_map):
|
||||
usage = Usage(
|
||||
prompt_tokens=0,
|
||||
|
|
@ -2481,8 +2475,6 @@ def test_vertex_global_or_absent_location_no_uplift(vertex_location, _local_mode
|
|||
assert base == located
|
||||
|
||||
|
||||
|
||||
|
||||
def test_vertex_uplift_invalid_multiplier_defaults_to_one():
|
||||
"""A malformed multiplier in the cost map degrades to base pricing, never raises."""
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
|
|
@ -2517,6 +2509,63 @@ def test_get_cost_per_unit_falls_back_from_service_tier_key_to_base():
|
|||
assert _get_cost_per_unit(model_info_direct, "input_cost_per_token_priority") == 5e-6
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field", ("output_cost_per_token", "cache_read_input_token_cost", "cache_creation_input_token_cost")
|
||||
)
|
||||
def test_flat_input_price_keeps_other_token_thresholds(field: str) -> None:
|
||||
from typing import Final, cast
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import _get_token_base_cost
|
||||
|
||||
info: Final = cast(
|
||||
ModelInfo,
|
||||
{
|
||||
"input_cost_per_token": 0.001,
|
||||
"output_cost_per_token": 0.002,
|
||||
"cache_read_input_token_cost": 0.003,
|
||||
"cache_creation_input_token_cost": 0.004,
|
||||
f"{field}_above_300k_tokens": 0.005,
|
||||
},
|
||||
)
|
||||
rates: Final = _get_token_base_cost(
|
||||
model_info=info,
|
||||
usage=Usage(prompt_tokens=300_001, completion_tokens=3, total_tokens=300_004),
|
||||
)
|
||||
assert rates[0] == 0.001
|
||||
assert rates[1] == (0.005 if field == "output_cost_per_token" else 0.002)
|
||||
assert rates[2] == (0.005 if field == "cache_creation_input_token_cost" else 0.004)
|
||||
assert rates[4] == (0.005 if field == "cache_read_input_token_cost" else 0.003)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt_tokens", (200_000, 200_001, 512_000, 512_001))
|
||||
@pytest.mark.parametrize("higher_rate", (0.0, 0.009))
|
||||
def test_mixed_token_thresholds_preserve_each_crossed_rate(prompt_tokens: int, higher_rate: float) -> None:
|
||||
from typing import Final, cast
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import _get_token_base_cost
|
||||
|
||||
info: Final = cast(
|
||||
ModelInfo,
|
||||
{
|
||||
"input_cost_per_token": 0.001,
|
||||
"output_cost_per_token": 0.002,
|
||||
"cache_read_input_token_cost": 0.003,
|
||||
"cache_creation_input_token_cost": 0.004,
|
||||
"input_cost_per_token_above_200k_tokens": 0.005,
|
||||
"output_cost_per_token_above_200k_tokens": 0.006,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 0.007,
|
||||
"cache_read_input_token_cost_above_512k_tokens": higher_rate,
|
||||
"output_cost_per_token_above_512k_tokens": higher_rate,
|
||||
},
|
||||
)
|
||||
rates: Final = _get_token_base_cost(
|
||||
model_info=info,
|
||||
usage=Usage(prompt_tokens=prompt_tokens, completion_tokens=3, total_tokens=prompt_tokens + 3),
|
||||
)
|
||||
assert rates[0] == (0.005 if prompt_tokens > 200_000 else 0.001)
|
||||
assert rates[1] == (higher_rate if prompt_tokens > 512_000 else 0.006 if prompt_tokens > 200_000 else 0.002)
|
||||
assert rates[2] == (0.007 if prompt_tokens > 200_000 else 0.004)
|
||||
assert rates[4] == (higher_rate if prompt_tokens > 512_000 else 0.003)
|
||||
|
||||
|
||||
def test_threshold_keys_exclude_service_tier_variants():
|
||||
from typing import cast
|
||||
|
||||
|
|
@ -3699,8 +3748,6 @@ def test_route_image_generation_cost_openai_honors_deployment_input_cost_per_ima
|
|||
assert cost == pytest.approx(0.07)
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("custom_llm_provider", "model"),
|
||||
[
|
||||
|
|
@ -3915,3 +3962,163 @@ def test_azure_gpt_5_6_alias_matches_sol_pricing(_local_model_cost_map, region_p
|
|||
assert shared_cost_fields
|
||||
for field in shared_cost_fields:
|
||||
assert alias[field] == sol[field], field
|
||||
|
||||
|
||||
# Per-token rates read 2026-10-07 from https://platform.claude.com/docs/en/about-claude/pricing (direct and
|
||||
# azure_ai, which Microsoft bills at Anthropic's rates per
|
||||
# https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/claude-models-billing) and from the
|
||||
# AmazonBedrockFoundationModels price list at
|
||||
# https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json (Bedrock)
|
||||
@pytest.mark.parametrize(
|
||||
("model", "custom_llm_provider", "prompt_tokens", "input_rate", "cache_read_rate", "output_rate"),
|
||||
[
|
||||
("claude-haiku-5-5", "anthropic", 100_000, 1e-07, 1e-08, 5e-07),
|
||||
("claude-haiku-5-5", "anthropic", 100_001, 5e-07, 5e-08, 2.5e-06),
|
||||
("azure_ai/claude-haiku-5-5", "azure_ai", 100_000, 1e-07, 1e-08, 5e-07),
|
||||
("azure_ai/claude-haiku-5-5", "azure_ai", 100_001, 5e-07, 5e-08, 2.5e-06),
|
||||
("global.anthropic.claude-haiku-5-5", "bedrock", 100_000, 1e-07, 1e-08, 5e-07),
|
||||
("global.anthropic.claude-haiku-5-5", "bedrock", 100_001, 5e-07, 5e-08, 2.5e-06),
|
||||
("us.anthropic.claude-haiku-5-5", "bedrock", 100_000, 1.1e-07, 1.1e-08, 5.5e-07),
|
||||
("us.anthropic.claude-haiku-5-5", "bedrock", 100_001, 5.5e-07, 5.5e-08, 2.75e-06),
|
||||
("us-gov.anthropic.claude-haiku-5-5", "bedrock", 100_000, 1.2e-07, 1.2e-08, 6e-07),
|
||||
("us-gov.anthropic.claude-haiku-5-5", "bedrock", 100_001, 6e-07, 6e-08, 3e-06),
|
||||
("bedrock_mantle/anthropic.claude-haiku-5-5", "bedrock_mantle", 100_001, 5.5e-07, 5.5e-08, 2.75e-06),
|
||||
("bedrock/us-gov-west-1/anthropic.claude-haiku-5-5", "bedrock", 100_001, 6e-07, 6e-08, 3e-06),
|
||||
("vertex_ai/claude-haiku-5-5", "vertex_ai", 100_000, 1e-07, 1e-08, 5e-07),
|
||||
("vertex_ai/claude-haiku-5-5", "vertex_ai", 100_001, 5e-07, 5e-08, 2.5e-06),
|
||||
],
|
||||
)
|
||||
def test_generic_cost_per_token_claude_haiku_5_5_prompt_length_tiers(
|
||||
_local_model_cost_map: None,
|
||||
model: str,
|
||||
custom_llm_provider: str,
|
||||
prompt_tokens: int,
|
||||
input_rate: float,
|
||||
cache_read_rate: float,
|
||||
output_rate: float,
|
||||
) -> None:
|
||||
"""Claude Haiku 5.5 bills every token at 5x the base rates once the prompt is over 100,000 tokens."""
|
||||
cached_tokens: Final = 10_000
|
||||
completion_tokens: Final = 1_000
|
||||
usage: Final = Usage(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=cached_tokens),
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = generic_cost_per_token(
|
||||
model=model,
|
||||
usage=usage,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
assert prompt_cost == pytest.approx((prompt_tokens - cached_tokens) * input_rate + cached_tokens * cache_read_rate)
|
||||
assert completion_cost == pytest.approx(completion_tokens * output_rate)
|
||||
|
||||
|
||||
def test_vertex_regional_endpoint_uplift_scales_claude_haiku_5_5_over_100k_rates(
|
||||
_local_model_cost_map: None,
|
||||
) -> None:
|
||||
"""Vertex regional endpoints bill 1.1x the global rate on all token types
|
||||
(https://cloud.google.com/vertex-ai/generative-ai/pricing, 2026-10-07: regional
|
||||
over-100K input is $0.55/MTok), so the uplift scales the over-100k rates too."""
|
||||
cached_tokens: Final = 10_000
|
||||
prompt_tokens: Final = 100_001
|
||||
completion_tokens: Final = 1_000
|
||||
usage: Final = Usage(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=cached_tokens),
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = generic_cost_per_token(
|
||||
model="vertex_ai/claude-haiku-5-5",
|
||||
usage=usage,
|
||||
custom_llm_provider="vertex_ai",
|
||||
vertex_location="us-east5",
|
||||
)
|
||||
|
||||
assert prompt_cost == pytest.approx((prompt_tokens - cached_tokens) * 5.5e-07 + cached_tokens * 5.5e-08)
|
||||
assert completion_cost == pytest.approx(completion_tokens * 2.75e-06)
|
||||
|
||||
|
||||
# Batch rates read 2026-10-07 from the Batch processing table at
|
||||
# https://platform.claude.com/docs/en/about-claude/pricing: $0.05 / $0.25 per MTok input and $0.25 / $1.25 output,
|
||||
# up to and over 100,000 prompt tokens
|
||||
@pytest.mark.parametrize(
|
||||
("prompt_tokens", "input_rate", "output_rate"),
|
||||
[(100_000, 5e-08, 2.5e-07), (100_001, 2.5e-07, 1.25e-06)],
|
||||
)
|
||||
def test_batch_cost_calculator_claude_haiku_5_5_prompt_length_tiers(
|
||||
_local_model_cost_map: None,
|
||||
prompt_tokens: int,
|
||||
input_rate: float,
|
||||
output_rate: float,
|
||||
) -> None:
|
||||
from litellm.cost_calculator import batch_cost_calculator
|
||||
|
||||
completion_tokens: Final = 1_000
|
||||
|
||||
prompt_cost, completion_cost = batch_cost_calculator(
|
||||
usage=Usage(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
),
|
||||
model="claude-haiku-5-5",
|
||||
custom_llm_provider="anthropic",
|
||||
)
|
||||
|
||||
assert prompt_cost == pytest.approx(prompt_tokens * input_rate)
|
||||
assert completion_cost == pytest.approx(completion_tokens * output_rate)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt_tokens", "expected"),
|
||||
[
|
||||
(100_000, (5e-08, 2.5e-07, 5e-09, 6.25e-08)),
|
||||
(100_001, (2.5e-07, 1.25e-06, 2.5e-08, 3.125e-07)),
|
||||
],
|
||||
)
|
||||
def test_get_batch_cost_rates_claude_haiku_5_5_prompt_length_tiers(
|
||||
_local_model_cost_map: None,
|
||||
prompt_tokens: int,
|
||||
expected: tuple[float, float, float, float],
|
||||
) -> None:
|
||||
"""Cache write and cache read batch rates are 50% of the standard rates; Anthropic's batch table omits them."""
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import get_batch_cost_rates
|
||||
|
||||
rates: Final = get_batch_cost_rates(
|
||||
litellm.get_model_info(model="claude-haiku-5-5", custom_llm_provider="anthropic"),
|
||||
Usage(prompt_tokens=prompt_tokens, completion_tokens=1, total_tokens=prompt_tokens + 1),
|
||||
"anthropic",
|
||||
)
|
||||
|
||||
assert (rates.input, rates.output, rates.cache_read, rates.cache_creation) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rate", (0.0, 0.001))
|
||||
def test_token_breakdown_uses_supplied_pricing_without_catalog_lookup(rate: float) -> None:
|
||||
model_info: Final[ModelInfo] = {
|
||||
"input_cost_per_token": 0.01,
|
||||
"output_cost_per_token": 0.02,
|
||||
"cache_read_input_token_cost": rate,
|
||||
"cache_creation_input_token_cost": rate,
|
||||
}
|
||||
usage: Final = Usage(
|
||||
prompt_tokens=20, completion_tokens=3, total_tokens=23,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=5), cache_creation_input_tokens=5,
|
||||
)
|
||||
prompt_cost, output_cost = generic_cost_per_token(
|
||||
model="supplied-pricing-test", usage=usage, custom_llm_provider="bedrock", model_info=model_info,
|
||||
)
|
||||
breakdown: Final = get_token_type_cost_breakdown(
|
||||
model="supplied-pricing-test", usage=usage, custom_llm_provider="bedrock", model_info=model_info,
|
||||
)
|
||||
assert breakdown.cache_read_cost == pytest.approx(5 * rate)
|
||||
assert breakdown.cache_creation_cost == pytest.approx(5 * rate)
|
||||
assert breakdown.rates is not None
|
||||
assert prompt_cost == pytest.approx(10 * breakdown.rates.input_cost_per_token + breakdown.cache_read_cost + breakdown.cache_creation_cost)
|
||||
assert output_cost == pytest.approx(3 * breakdown.rates.output_cost_per_token)
|
||||
|
|
|
|||
|
|
@ -369,9 +369,9 @@ def test_web_search_provider_prefix_fallback_does_not_misprice_non_gemini_model(
|
|||
"""
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
|
||||
|
||||
model = "openrouter/google/gemini-3.1-flash-lite"
|
||||
model = "azure/gpt-4o"
|
||||
model_info = litellm.get_model_info(model)
|
||||
assert model_info["litellm_provider"] == "openrouter"
|
||||
assert model_info["litellm_provider"] == "azure"
|
||||
assert not model_info.get("search_context_cost_per_query")
|
||||
|
||||
usage = Usage(
|
||||
|
|
|
|||
|
|
@ -9369,3 +9369,88 @@ async def test_background_interaction_completion_logs_while_in_progress_handler_
|
|||
await completion
|
||||
|
||||
assert counting_logger.logged_results == [completed, in_progress], counting_logger.logged_results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_cost_per_token", "output_cost_per_token"),
|
||||
[(1e-06, 2e-06), (0.0, 0.0)],
|
||||
ids=["custom_price", "explicit_zero_price"],
|
||||
)
|
||||
def test_standard_logging_payload_reports_the_deployment_entry_the_request_was_billed_from(
|
||||
input_cost_per_token: float, output_cost_per_token: float
|
||||
):
|
||||
"""A deployment with its own price keeps the catalog model as model_map_key but is logged at its own rates."""
|
||||
from litellm.litellm_core_utils.litellm_logging import get_standard_logging_object_payload
|
||||
|
||||
deployment_id: Final = "lit9660-priced-deployment"
|
||||
deployment_model_info: Final = {
|
||||
"id": deployment_id,
|
||||
"input_cost_per_token": input_cost_per_token,
|
||||
"output_cost_per_token": output_cost_per_token,
|
||||
}
|
||||
litellm.register_model(
|
||||
model_cost={
|
||||
deployment_id: {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": input_cost_per_token,
|
||||
"output_cost_per_token": output_cost_per_token,
|
||||
}
|
||||
},
|
||||
persist_across_reloads=False,
|
||||
)
|
||||
try:
|
||||
logging_obj: Final = LitellmLogging(
|
||||
model="gpt-5.4-nano",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time=time.time(),
|
||||
litellm_call_id="lit9660",
|
||||
function_id="fn",
|
||||
)
|
||||
litellm_params: Final = {
|
||||
"metadata": {
|
||||
"model_group": "lit9660-group",
|
||||
"deployment": "openai/gpt-5.4-nano",
|
||||
"model_info": deployment_model_info,
|
||||
}
|
||||
}
|
||||
logging_obj.update_environment_variables(
|
||||
model="gpt-5.4-nano", user="", optional_params={}, litellm_params=litellm_params, custom_llm_provider="openai"
|
||||
)
|
||||
response: Final = ModelResponse(
|
||||
model="gpt-5.4-nano",
|
||||
choices=[litellm.Choices(message=litellm.Message(role="assistant", content="hi"))],
|
||||
usage=litellm.Usage(prompt_tokens=10, completion_tokens=6, total_tokens=16),
|
||||
)
|
||||
|
||||
response_cost: Final = logging_obj._response_cost_calculator(result=response)
|
||||
payload: Final = get_standard_logging_object_payload(
|
||||
kwargs={
|
||||
"model": "gpt-5.4-nano",
|
||||
"custom_llm_provider": "openai",
|
||||
"litellm_params": litellm_params,
|
||||
"response_cost": response_cost,
|
||||
},
|
||||
init_response_obj=response,
|
||||
start_time=datetime.datetime.now(),
|
||||
end_time=datetime.datetime.now(),
|
||||
logging_obj=logging_obj,
|
||||
status="success",
|
||||
)
|
||||
|
||||
assert response_cost == 10 * input_cost_per_token + 6 * output_cost_per_token
|
||||
assert payload is not None
|
||||
assert payload["model_map_information"] == {
|
||||
"model_map_key": "openai/gpt-5.4-nano",
|
||||
"model_map_value": litellm.get_model_info(model=f"openai/{deployment_id}", custom_llm_provider="openai"),
|
||||
}
|
||||
model_map_value: Final = payload["model_map_information"]["model_map_value"]
|
||||
assert model_map_value is not None
|
||||
assert (model_map_value["input_cost_per_token"], model_map_value["output_cost_per_token"]) == (
|
||||
input_cost_per_token,
|
||||
output_cost_per_token,
|
||||
)
|
||||
finally:
|
||||
litellm.model_cost.pop(deployment_id, None)
|
||||
|
|
|
|||
|
|
@ -205,6 +205,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",
|
||||
[
|
||||
|
|
@ -212,13 +229,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(
|
||||
self, local_model_cost_map, model, provider
|
||||
):
|
||||
"""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."""
|
||||
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:
|
||||
"""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):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
|
|
@ -343,17 +346,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={},
|
||||
|
|
@ -365,14 +373,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
|
||||
|
|
|
|||
|
|
@ -651,16 +651,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
|
||||
|
|
@ -679,6 +684,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"}
|
||||
|
||||
|
||||
def test_azure_responses_sends_the_deployment_name_when_azure_ai_prefix_survives_provider_remap():
|
||||
request = AzureOpenAIResponsesAPIConfig().transform_responses_api_request(
|
||||
model="azure_ai/gpt-5.4-nano",
|
||||
|
|
|
|||
|
|
@ -146,17 +146,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():
|
||||
|
|
|
|||
|
|
@ -7,13 +7,17 @@ sys.path.insert(
|
|||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../.."))
|
||||
)
|
||||
|
||||
from typing import Final
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.anthropic.messages_transformation import (
|
||||
AzureAnthropicMessagesConfig,
|
||||
)
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
|
||||
|
|
@ -470,3 +474,80 @@ def test_azure_claude_4_8_plus_cost_map_entries_carry_mid_conversation_system_fl
|
|||
and info.get("supports_mid_conversation_system") is not True
|
||||
]
|
||||
assert missing == []
|
||||
|
||||
|
||||
AUTO_MODE_SAFEGUARDS: Final = ({"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}},)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_beta_headers_config")
|
||||
@pytest.mark.parametrize(
|
||||
("client_headers", "optional_params", "expected_beta"),
|
||||
[
|
||||
({}, {"safeguards": AUTO_MODE_SAFEGUARDS}, "dangerous-tool-use-2026-09-03"),
|
||||
(
|
||||
{"anthropic-beta": "context-1m-2025-08-07"},
|
||||
{"safeguards": AUTO_MODE_SAFEGUARDS},
|
||||
"context-1m-2025-08-07,dangerous-tool-use-2026-09-03",
|
||||
),
|
||||
({"anthropic-beta": "context-1m-2025-08-07"}, {}, "context-1m-2025-08-07"),
|
||||
({}, {}, None),
|
||||
],
|
||||
)
|
||||
def test_safeguards_request_carries_the_dangerous_tool_use_beta_without_the_client_header(
|
||||
client_headers: dict[str, str], optional_params: dict[str, object], expected_beta: str | None
|
||||
) -> None:
|
||||
headers, _ = AzureAnthropicMessagesConfig().validate_anthropic_messages_environment(
|
||||
headers=client_headers,
|
||||
model="claude-sonnet-4-6",
|
||||
messages=[{"role": "user", "content": "Run ls -la with the Bash tool"}],
|
||||
optional_params=optional_params,
|
||||
litellm_params={"api_key": "test-api-key"},
|
||||
)
|
||||
assert headers.get("anthropic-beta") == expected_beta, headers
|
||||
|
||||
|
||||
def _foundry_answer(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
json={
|
||||
"id": "msg_safeguards",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"content": [{"type": "text", "text": "hello from the gateway"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 12, "output_tokens": 6},
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("local_beta_headers_config")
|
||||
async def test_outgoing_azure_request_sends_safeguards_with_the_dangerous_tool_use_beta() -> None:
|
||||
received: Final[list[httpx.Request]] = []
|
||||
|
||||
def foundry_answers(request: httpx.Request) -> httpx.Response:
|
||||
received.append(request)
|
||||
return _foundry_answer(request)
|
||||
|
||||
upstream: Final = AsyncHTTPHandler(transport=httpx.MockTransport(foundry_answers))
|
||||
try:
|
||||
await litellm.anthropic.messages.acreate(
|
||||
max_tokens=256,
|
||||
messages=[{"role": "user", "content": "Use the Bash tool to run: echo hello from the gateway"}],
|
||||
model="azure_ai/claude-sonnet-4-6",
|
||||
api_key="test-api-key",
|
||||
api_base="https://test-resource.services.ai.azure.com",
|
||||
safeguards=AUTO_MODE_SAFEGUARDS,
|
||||
client=upstream,
|
||||
)
|
||||
finally:
|
||||
await upstream.close()
|
||||
|
||||
assert len(received) == 1, received
|
||||
sent: Final = received[0]
|
||||
assert "dangerous-tool-use-2026-09-03" in sent.headers["anthropic-beta"].split(","), dict(sent.headers)
|
||||
assert json.loads(sent.content)["safeguards"] == list(AUTO_MODE_SAFEGUARDS)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import pytest
|
|||
from botocore.credentials import Credentials
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
import litellm
|
||||
|
||||
from litellm.llms.bedrock.chat.converse_handler import BedrockConverseLLM
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.rust_bridge import configuration
|
||||
|
|
@ -273,3 +275,44 @@ def test_session_tags_sign_the_request_and_stay_out_of_the_body(monkeypatch):
|
|||
sent = client.post.call_args.kwargs
|
||||
assert "Credential=ASIACONVERSETAGGED/" in sent["headers"]["Authorization"]
|
||||
assert "aws_session_tags" not in sent["data"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", (False, True))
|
||||
def test_converse_profile_override_records_cost_without_changing_response_model(
|
||||
monkeypatch: pytest.MonkeyPatch, stream: bool
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setenv("LITELLM_RUST", "0")
|
||||
configuration.rust(False)
|
||||
model: Final = "openai.gpt-5.6-sol"
|
||||
profile: Final = f"us.{model}"
|
||||
prices: Final = litellm.get_model_info(profile, custom_llm_provider="bedrock")
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == f"/model/{profile}/converse"
|
||||
return httpx.Response(200, json=CONVERSE_RESPONSE)
|
||||
|
||||
with httpx.Client(transport=httpx.MockTransport(respond)) as transport:
|
||||
response: Final = litellm.completion(
|
||||
model=f"bedrock/converse/{model}",
|
||||
model_id=profile,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_key="test-bearer",
|
||||
aws_region_name="us-east-1",
|
||||
client=HTTPHandler(client=transport),
|
||||
stream=stream,
|
||||
fake_stream=stream,
|
||||
max_tokens=16,
|
||||
)
|
||||
result: Final = litellm.stream_chunk_builder(list(response)) if stream else response
|
||||
|
||||
assert result is not None
|
||||
assert result.model == model
|
||||
assert result.choices[0].message.content == "hi"
|
||||
cost: Final = (
|
||||
response.logging_obj._response_cost_calculator(result) if stream else result._hidden_params.get("response_cost")
|
||||
)
|
||||
assert cost == pytest.approx(
|
||||
CONVERSE_RESPONSE["usage"]["inputTokens"] * prices["input_cost_per_token"]
|
||||
+ CONVERSE_RESPONSE["usage"]["outputTokens"] * prices["output_cost_per_token"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5764,6 +5764,67 @@ def test_transform_response_finish_reason_stop_when_json_mode_filters_all_tools(
|
|||
assert result.choices[0].finish_reason == "stop"
|
||||
|
||||
|
||||
def test_transform_response_json_mode_truncated_tool_call_keeps_length_finish_reason():
|
||||
"""
|
||||
When json_mode filters out the synthetic json_tool_call but Bedrock
|
||||
stopped on max_tokens, finish_reason must stay "length", not be
|
||||
downgraded to "stop" — otherwise truncated structured output looks
|
||||
completed.
|
||||
"""
|
||||
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
||||
response_json = {
|
||||
"metrics": {"latencyMs": 100},
|
||||
"output": {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"toolUse": {
|
||||
"toolUseId": "tooluse_001",
|
||||
"name": "json_tool_call",
|
||||
"input": {"a": "cut"},
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"stopReason": "max_tokens",
|
||||
"usage": {
|
||||
"inputTokens": 10,
|
||||
"outputTokens": 60,
|
||||
"totalTokens": 70,
|
||||
},
|
||||
}
|
||||
|
||||
class MockResponse:
|
||||
def json(self) -> dict[str, object]:
|
||||
return response_json
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return json.dumps(response_json)
|
||||
|
||||
config = AmazonConverseConfig()
|
||||
model_response = ModelResponse()
|
||||
|
||||
result = config._transform_response(
|
||||
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
response=MockResponse(),
|
||||
model_response=model_response,
|
||||
stream=False,
|
||||
logging_obj=None,
|
||||
optional_params={"json_mode": True},
|
||||
api_key=None,
|
||||
data=None,
|
||||
messages=[],
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
assert result.choices[0].finish_reason == "length"
|
||||
|
||||
|
||||
def test_transform_response_citations_content_maps_to_annotations():
|
||||
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
|
|
|||
|
|
@ -4367,3 +4367,45 @@ async def test_async_text_to_speech_handler_records_upstream_response_headers():
|
|||
|
||||
assert response.content == b"audio-bytes"
|
||||
_assert_upstream_headers_recorded(response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("local_beta_headers_config")
|
||||
async def test_anthropic_messages_dropped_safeguards_send_neither_the_field_nor_its_beta() -> None:
|
||||
received: Final[list[httpx.Request]] = []
|
||||
|
||||
def anthropic_answers(request: httpx.Request) -> httpx.Response:
|
||||
received.append(request)
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
"id": "msg_dropped_safeguards",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"content": [{"type": "text", "text": "hi"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 5, "output_tokens": 1},
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
upstream: Final = AsyncHTTPHandler(transport=httpx.MockTransport(anthropic_answers))
|
||||
try:
|
||||
await litellm.anthropic.messages.acreate(
|
||||
max_tokens=16,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="anthropic/claude-sonnet-4-6",
|
||||
api_key="sk-ant-test",
|
||||
safeguards=({"type": "dangerous_tool_use"},),
|
||||
additional_drop_params=["safeguards"],
|
||||
client=upstream,
|
||||
)
|
||||
finally:
|
||||
await upstream.close()
|
||||
|
||||
assert len(received) == 1, received
|
||||
sent: Final = received[0]
|
||||
assert "safeguards" not in json.loads(sent.content)
|
||||
assert "dangerous-tool-use-2026-09-03" not in sent.headers.get("anthropic-beta", ""), dict(sent.headers)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import copy
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -16,11 +19,33 @@ from litellm.llms.databricks.chat.transformation import (
|
|||
DatabricksConfig,
|
||||
_sanitize_empty_content,
|
||||
)
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from typing import Final
|
||||
import httpx
|
||||
import respx
|
||||
|
||||
|
||||
JSON_SCHEMA_RESPONSE_FORMAT: Final = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "P",
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"$defs": {
|
||||
"Person": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"properties": {"p": {"$ref": "#/$defs/Person"}},
|
||||
"required": ["p"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _use_local_model_cost_map(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
|
|
@ -283,6 +308,119 @@ def test_transform_request_preserves_unity_model_service_name():
|
|||
assert result["model"] == "system.ai.kimi-k3"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,messages,optional_params,expected_content",
|
||||
[
|
||||
pytest.param(
|
||||
"databricks-meta-llama-3-3-70b-instruct",
|
||||
[{"role": "user", "content": [{"type": "text", "text": "Reply in JSON"}]}],
|
||||
{"response_format": {"type": "json_object"}},
|
||||
"Reply in JSON",
|
||||
id="user",
|
||||
),
|
||||
pytest.param(
|
||||
"databricks-meta-llama-3-3-70b-instruct",
|
||||
[{"role": "system", "content": [{"type": "text", "text": "Reply in JSON"}]}],
|
||||
{"response_format": {"type": "json_object"}},
|
||||
"Reply in JSON",
|
||||
id="system",
|
||||
),
|
||||
pytest.param(
|
||||
"databricks-meta-llama-3-3-70b-instruct",
|
||||
[{"role": "assistant", "content": [{"type": "text", "text": "Reply in JSON"}]}],
|
||||
{"response_format": {"type": "json_object"}},
|
||||
"Reply in JSON",
|
||||
id="assistant",
|
||||
),
|
||||
pytest.param(
|
||||
"databricks-meta-llama-3-3-70b-instruct",
|
||||
[
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": [{"type": "text", "text": "Reply in JSON"}],
|
||||
}
|
||||
],
|
||||
{"response_format": {"type": "json_object"}},
|
||||
"Reply in JSON",
|
||||
id="tool",
|
||||
),
|
||||
pytest.param(
|
||||
"databricks-meta-llama-3-3-70b-instruct",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Reply in JSON", "cache_control": {"type": "ephemeral"}}],
|
||||
}
|
||||
],
|
||||
{"response_format": {"type": "json_object"}},
|
||||
[{"type": "text", "text": "Reply in JSON", "cache_control": {"type": "ephemeral"}}],
|
||||
id="cache-control",
|
||||
),
|
||||
pytest.param(
|
||||
"databricks-meta-llama-3-3-70b-instruct",
|
||||
[{"role": "user", "content": [{"type": "text", "text": "Reply"}, {"type": "text", "text": " in JSON"}]}],
|
||||
{"response_format": {"type": "json_object"}},
|
||||
[{"type": "text", "text": "Reply"}, {"type": "text", "text": " in JSON"}],
|
||||
id="multiple-text-blocks",
|
||||
),
|
||||
pytest.param(
|
||||
"databricks-meta-llama-3-3-70b-instruct",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Reply in JSON"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}},
|
||||
],
|
||||
}
|
||||
],
|
||||
{"response_format": {"type": "json_object"}},
|
||||
[
|
||||
{"type": "text", "text": "Reply in JSON"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}},
|
||||
],
|
||||
id="text-and-image",
|
||||
),
|
||||
pytest.param(
|
||||
"databricks-meta-llama-3-3-70b-instruct",
|
||||
[{"role": "user", "content": "Reply in JSON"}],
|
||||
{"response_format": {"type": "json_object"}},
|
||||
"Reply in JSON",
|
||||
id="plain-string",
|
||||
),
|
||||
pytest.param(
|
||||
"databricks-claude-sonnet-5",
|
||||
[{"role": "user", "content": [{"type": "text", "text": "Reply in JSON"}]}],
|
||||
{"response_format": {"type": "json_object"}},
|
||||
[{"type": "text", "text": "Reply in JSON"}],
|
||||
id="claude",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_transform_request_collapses_single_plain_text_block_for_non_claude_models(
|
||||
model: str,
|
||||
messages: list[AllMessageValues],
|
||||
optional_params: dict[str, object],
|
||||
expected_content: object,
|
||||
) -> None:
|
||||
config: Final[DatabricksConfig] = DatabricksConfig()
|
||||
result: Final[dict[str, object]] = TypeAdapter(dict[str, object]).validate_python(
|
||||
config.transform_request( # pyright: ignore[reportUnknownMemberType] # inherited hook is untyped
|
||||
model=model,
|
||||
messages=messages,
|
||||
optional_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
)
|
||||
outgoing_messages: Final[list[dict[str, object]]] = TypeAdapter(list[dict[str, object]]).validate_python(
|
||||
result["messages"]
|
||||
)
|
||||
|
||||
assert outgoing_messages == [{**messages[0], "content": expected_content}]
|
||||
|
||||
|
||||
def test_transform_request_strips_thinking_blocks_and_reasoning_content():
|
||||
"""Regression for LIT-6762: replaying an assistant turn that litellm decorated with
|
||||
`thinking_blocks` / `reasoning_content` made Databricks 400 with
|
||||
|
|
@ -893,3 +1031,69 @@ def test_chunk_parser_relays_the_served_service_tier():
|
|||
|
||||
without_tier: Final = iterator.chunk_parser(_streaming_chunk())
|
||||
assert getattr(without_tier, "service_tier", None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
(
|
||||
"databricks-meta-llama-3-3-70b-instruct",
|
||||
"databricks-qwen35-122b-a10b",
|
||||
"databricks-gpt-oss-120b",
|
||||
"system.ai.deepseek-v4-1-flash",
|
||||
),
|
||||
)
|
||||
def test_databricks_non_claude_json_schema_preserves_refs(model: str) -> None:
|
||||
optional_params: Final = litellm.utils.get_optional_params(
|
||||
model=model,
|
||||
custom_llm_provider="databricks",
|
||||
response_format=copy.deepcopy(JSON_SCHEMA_RESPONSE_FORMAT),
|
||||
)
|
||||
|
||||
assert optional_params["response_format"] == JSON_SCHEMA_RESPONSE_FORMAT
|
||||
|
||||
|
||||
def test_databricks_claude_json_schema_preserves_refs_in_tool_parameters() -> None:
|
||||
optional_params: Final = litellm.utils.get_optional_params(
|
||||
model="databricks-claude-haiku-4-5",
|
||||
custom_llm_provider="databricks",
|
||||
response_format=copy.deepcopy(JSON_SCHEMA_RESPONSE_FORMAT),
|
||||
)
|
||||
|
||||
assert "response_format" not in optional_params
|
||||
assert optional_params["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "json_tool_call",
|
||||
"parameters": JSON_SCHEMA_RESPONSE_FORMAT["json_schema"]["schema"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_databricks_pydantic_json_schema_preserves_nested_refs() -> None:
|
||||
class Person(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
name: str
|
||||
|
||||
class Order(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
person: Person
|
||||
|
||||
optional_params: Final = litellm.utils.get_optional_params(
|
||||
model="databricks-meta-llama-3-3-70b-instruct",
|
||||
custom_llm_provider="databricks",
|
||||
response_format=Order,
|
||||
)
|
||||
schema: Final = optional_params["response_format"]["json_schema"]["schema"]
|
||||
|
||||
assert schema["properties"]["person"] == {"$ref": "#/$defs/Person"}
|
||||
assert schema["$defs"]["Person"] == {
|
||||
"additionalProperties": False,
|
||||
"properties": {"name": {"title": "Name", "type": "string"}},
|
||||
"required": ["name"],
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1858,6 +1858,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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -516,3 +516,26 @@ def test_request_strips_ttl_only_where_the_messages_api_defines_cache_control(co
|
|||
assert tool_result["content"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
assert payload["messages"][1]["content"][1]["cache_control"] == {"type": "ephemeral"}
|
||||
assert payload["messages"][2] == {"role": "user", "content": "a plain string message"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_beta_headers_config")
|
||||
@pytest.mark.parametrize(
|
||||
("client_headers", "expected_beta"),
|
||||
[
|
||||
({}, None),
|
||||
({"anthropic-beta": "dangerous-tool-use-2026-09-03"}, "dangerous-tool-use-2026-09-03"),
|
||||
],
|
||||
)
|
||||
def test_safeguards_reach_an_openai_like_host_with_only_the_client_betas(
|
||||
config: OpenAILikeAnthropicMessagesConfig, client_headers: dict[str, str], expected_beta: str | None
|
||||
) -> None:
|
||||
headers, _ = config.validate_anthropic_messages_environment(
|
||||
headers=client_headers,
|
||||
model="some-model",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
optional_params={"safeguards": ({"type": "dangerous_tool_use"},)},
|
||||
litellm_params={},
|
||||
api_key="sk-test",
|
||||
api_base="https://host/v1",
|
||||
)
|
||||
assert headers.get("anthropic-beta") == expected_beta, headers
|
||||
|
|
|
|||
|
|
@ -9,12 +9,17 @@ ARN unified_object_id) batches with no managed unified id.
|
|||
import asyncio
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
from copy import copy
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.types.utils import LiteLLMBatch
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.batches.batch_utils import BatchCostUsageResult
|
||||
|
||||
|
|
@ -201,7 +206,7 @@ class TestCheckBatchCost:
|
|||
|
||||
find_call = mock_prisma_client.db.litellm_managedobjecttable.find_many.call_args
|
||||
assert find_call[1]["take"] == MAX_OBJECTS_PER_POLL_CYCLE
|
||||
assert find_call[1]["order"] == {"created_at": "asc"}
|
||||
assert find_call[1]["order"] == [{"created_at": "asc"}, {"id": "asc"}]
|
||||
not_in = find_call[1]["where"]["status"]["not_in"]
|
||||
assert "stale_expired" in not_in
|
||||
# "complete"/"completed" are intentionally NOT excluded from the
|
||||
|
|
@ -226,15 +231,13 @@ class TestCheckBatchCost:
|
|||
)
|
||||
# First find_many (primary query) raises with a schema error; second (fallback) returns empty
|
||||
mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(
|
||||
side_effect=[Exception("column batch_processed does not exist"), []]
|
||||
side_effect=[Exception("column batch_processed does not exist"), [], []]
|
||||
)
|
||||
|
||||
await check_batch_cost_instance.check_batch_cost()
|
||||
|
||||
calls = (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.find_many.call_args_list
|
||||
)
|
||||
assert len(calls) == 2
|
||||
calls = mock_prisma_client.db.litellm_managedobjecttable.find_many.call_args_list
|
||||
assert len(calls) == 3
|
||||
fallback_where = calls[1][1]["where"]
|
||||
assert "batch_processed" not in fallback_where
|
||||
assert "stale_expired" in fallback_where["status"]["not_in"]
|
||||
|
|
@ -261,15 +264,8 @@ class TestCheckBatchCost:
|
|||
|
||||
await check_batch_cost_instance.check_batch_cost()
|
||||
|
||||
# Only one find_many call — the fallback directly, no primary query attempt
|
||||
assert (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.find_many.call_count == 1
|
||||
)
|
||||
fallback_where = (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.find_many.call_args[1][
|
||||
"where"
|
||||
]
|
||||
)
|
||||
assert mock_prisma_client.db.litellm_managedobjecttable.find_many.call_count == 2
|
||||
fallback_where = mock_prisma_client.db.litellm_managedobjecttable.find_many.call_args[1]["where"]
|
||||
assert "batch_processed" not in fallback_where
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -512,10 +508,16 @@ class TestCheckBatchCost:
|
|||
mock_job.created_by = "user-1"
|
||||
mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(return_value=[mock_job])
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = "completed"
|
||||
mock_response.output_file_id = "file-output-123"
|
||||
mock_response.model_dump_json.return_value = '{"id":"batch-1","status":"completed"}'
|
||||
mock_response: Final = LiteLLMBatch(
|
||||
id="batch-1",
|
||||
status="completed",
|
||||
output_file_id="file-output-123",
|
||||
created_at=1,
|
||||
completion_window="24h",
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id="file-input-1",
|
||||
object="batch",
|
||||
)
|
||||
|
||||
mock_llm_router.aretrieve_batch = AsyncMock(return_value=mock_response)
|
||||
mock_llm_router.get_deployment_credentials_with_provider = MagicMock(
|
||||
|
|
@ -696,11 +698,15 @@ class TestCheckBatchCost:
|
|||
return_value=[mock_job]
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = "completed"
|
||||
mock_response.output_file_id = "file-output-123"
|
||||
mock_response.model_dump_json.return_value = (
|
||||
'{"id":"batch-1","status":"completed"}'
|
||||
mock_response: Final = LiteLLMBatch(
|
||||
id="batch-1",
|
||||
status="completed",
|
||||
output_file_id="file-output-123",
|
||||
created_at=1,
|
||||
completion_window="24h",
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id="file-input-1",
|
||||
object="batch",
|
||||
)
|
||||
|
||||
mock_llm_router.aretrieve_batch = AsyncMock(return_value=mock_response)
|
||||
|
|
@ -764,15 +770,13 @@ class TestCheckBatchCost:
|
|||
|
||||
await check_batch_cost_instance.check_batch_cost()
|
||||
|
||||
assert (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1
|
||||
), "Expected update() to be called exactly once for the completed job"
|
||||
update_data = mock_prisma_client.db.litellm_managedobjecttable.update.call_args[
|
||||
1
|
||||
]["data"]
|
||||
assert (
|
||||
update_data["batch_processed"] is True
|
||||
), "update() must include batch_processed=True when column is present"
|
||||
assert mock_prisma_client.db.litellm_managedobjecttable.update_many.call_count == 2, (
|
||||
"Expected update() to be called exactly once for the completed job"
|
||||
)
|
||||
update_data = mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args[1]["data"]
|
||||
assert update_data["batch_processed"] is True, (
|
||||
"update() must include batch_processed=True when column is present"
|
||||
)
|
||||
assert update_data["status"] == "complete"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -899,7 +903,7 @@ class TestCheckBatchCost:
|
|||
|
||||
mock_update_database.assert_awaited_once()
|
||||
assert mock_update_database.call_args.kwargs["response_cost"] == 0.01
|
||||
assert mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1, (
|
||||
assert mock_prisma_client.db.litellm_managedobjecttable.update_many.call_count == 2, (
|
||||
"the job must still be marked processed once cost tracking succeeds"
|
||||
)
|
||||
|
||||
|
|
@ -1015,12 +1019,10 @@ class TestCheckBatchCost:
|
|||
|
||||
await check_batch_cost_instance.check_batch_cost()
|
||||
|
||||
assert (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1
|
||||
), f"Expected update() to be called exactly once for a {terminal_status} job"
|
||||
update_data = mock_prisma_client.db.litellm_managedobjecttable.update.call_args[
|
||||
1
|
||||
]["data"]
|
||||
assert mock_prisma_client.db.litellm_managedobjecttable.update_many.call_count == 2, (
|
||||
f"Expected update() to be called exactly once for a {terminal_status} job"
|
||||
)
|
||||
update_data = mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args[1]["data"]
|
||||
assert update_data["status"] == terminal_status
|
||||
assert (
|
||||
update_data["batch_processed"] is True
|
||||
|
|
@ -1126,9 +1128,13 @@ class TestCheckBatchCost:
|
|||
assert store_call.kwargs["user_api_key_dict"].user_id == "user-1"
|
||||
assert store_call.kwargs["user_api_key_dict"].team_id == "team-1"
|
||||
|
||||
assert mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1
|
||||
update_call = mock_prisma_client.db.litellm_managedobjecttable.update.call_args
|
||||
assert update_call.kwargs["where"] == {"id": "job-terminal-mint-1"}
|
||||
assert mock_prisma_client.db.litellm_managedobjecttable.update_many.call_count == 2
|
||||
update_call = mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args
|
||||
assert update_call.kwargs["where"] == {
|
||||
"id": "job-terminal-mint-1",
|
||||
"batch_processed": False,
|
||||
"updated_at": mock_job.updated_at,
|
||||
}
|
||||
update_data = update_call.kwargs["data"]
|
||||
assert update_data["status"] == terminal_status
|
||||
assert update_data["batch_processed"] is True
|
||||
|
|
@ -1203,12 +1209,10 @@ class TestCheckBatchCost:
|
|||
) as mock_afile_content:
|
||||
await check_batch_cost_instance.check_batch_cost()
|
||||
|
||||
assert (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1
|
||||
), "a completed batch with no output file must be marked processed exactly once"
|
||||
update_data = mock_prisma_client.db.litellm_managedobjecttable.update.call_args[
|
||||
1
|
||||
]["data"]
|
||||
assert mock_prisma_client.db.litellm_managedobjecttable.update_many.call_count == 2, (
|
||||
"a completed batch with no output file must be marked processed exactly once"
|
||||
)
|
||||
update_data = mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args[1]["data"]
|
||||
assert update_data["status"] == completed_status
|
||||
assert (
|
||||
update_data["batch_processed"] is True
|
||||
|
|
@ -1374,11 +1378,15 @@ class TestCheckBatchCost:
|
|||
return_value=[mock_job]
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = terminal_status
|
||||
mock_response.output_file_id = "file-output-123"
|
||||
mock_response.model_dump_json.return_value = (
|
||||
f'{{"id":"batch-1","status":"{terminal_status}"}}'
|
||||
mock_response: Final = LiteLLMBatch(
|
||||
id="batch-1",
|
||||
status=terminal_status,
|
||||
output_file_id="file-output-123",
|
||||
created_at=1,
|
||||
completion_window="24h",
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id="file-input-1",
|
||||
object="batch",
|
||||
)
|
||||
|
||||
mock_llm_router.aretrieve_batch = AsyncMock(return_value=mock_response)
|
||||
|
|
@ -1446,12 +1454,8 @@ class TestCheckBatchCost:
|
|||
mock_afile_content.await_count == 1
|
||||
), f"{terminal_status} batch with an output file must fetch results and be billed"
|
||||
mock_logging_obj.async_success_handler.assert_awaited_once()
|
||||
assert (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1
|
||||
)
|
||||
update_data = mock_prisma_client.db.litellm_managedobjecttable.update.call_args[
|
||||
1
|
||||
]["data"]
|
||||
assert mock_prisma_client.db.litellm_managedobjecttable.update_many.call_count == 2
|
||||
update_data = mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args[1]["data"]
|
||||
assert update_data["batch_processed"] is True
|
||||
assert (
|
||||
update_data["status"] == terminal_status
|
||||
|
|
@ -1633,12 +1637,10 @@ class TestCheckBatchCost:
|
|||
|
||||
assert mock_afile_content.await_count == 1
|
||||
mock_calculate.assert_not_awaited()
|
||||
assert (
|
||||
mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1
|
||||
), "a terminal batch with a 404ing output file must be retired, not retried forever"
|
||||
update_data = mock_prisma_client.db.litellm_managedobjecttable.update.call_args[
|
||||
1
|
||||
]["data"]
|
||||
assert mock_prisma_client.db.litellm_managedobjecttable.update_many.call_count == 2, (
|
||||
"a terminal batch with a 404ing output file must be retired, not retried forever"
|
||||
)
|
||||
update_data = mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args[1]["data"]
|
||||
assert update_data["status"] == "failed"
|
||||
assert update_data["batch_processed"] is True
|
||||
|
||||
|
|
@ -1970,14 +1972,15 @@ class TestUnmanagedVertexRouting:
|
|||
router.resolve_model_name_from_model_id.return_value = "gemini-2.5-flash"
|
||||
router.get_model_ids.return_value = ["deploy-1"]
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = "completed"
|
||||
mock_response.output_file_id = "gs://bucket/out/predictions.jsonl"
|
||||
mock_response.error_file_id = None
|
||||
mock_response.completed_at = None
|
||||
mock_response.created_at = None
|
||||
mock_response.model_dump_json.return_value = (
|
||||
'{"id":"8823717160934178816","status":"completed"}'
|
||||
mock_response: Final = LiteLLMBatch(
|
||||
id="8823717160934178816",
|
||||
status="completed",
|
||||
output_file_id="gs://bucket/out/predictions.jsonl",
|
||||
created_at=1,
|
||||
completion_window="24h",
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id="file-input-1",
|
||||
object="batch",
|
||||
)
|
||||
router.aretrieve_batch = AsyncMock(return_value=mock_response)
|
||||
router.get_deployment_credentials_with_provider = MagicMock(
|
||||
|
|
@ -2050,8 +2053,8 @@ class TestUnmanagedVertexRouting:
|
|||
mock_logging_obj.async_success_handler.assert_awaited_once()
|
||||
assert mock_logging_obj.async_success_handler.call_args[1]["batch_cost"] == 0.01
|
||||
|
||||
assert prisma.db.litellm_managedobjecttable.update.call_count == 1
|
||||
update_data = prisma.db.litellm_managedobjecttable.update.call_args[1]["data"]
|
||||
assert prisma.db.litellm_managedobjecttable.update_many.call_count == 2
|
||||
update_data = prisma.db.litellm_managedobjecttable.update_many.call_args[1]["data"]
|
||||
assert update_data["batch_processed"] is True
|
||||
assert update_data["status"] == "complete"
|
||||
|
||||
|
|
@ -2202,14 +2205,15 @@ class TestUnmanagedBedrockRouting:
|
|||
router.resolve_model_name_from_model_id.return_value = "claude-sonnet-4"
|
||||
router.get_model_ids.return_value = ["deploy-1"]
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = "completed"
|
||||
mock_response.output_file_id = "s3://bucket/out/predictions.jsonl"
|
||||
mock_response.error_file_id = None
|
||||
mock_response.completed_at = None
|
||||
mock_response.created_at = None
|
||||
mock_response.model_dump_json.return_value = (
|
||||
f'{{"id":"{self._ARN}","status":"completed"}}'
|
||||
mock_response: Final = LiteLLMBatch(
|
||||
id=self._ARN,
|
||||
status="completed",
|
||||
output_file_id="s3://bucket/out/predictions.jsonl",
|
||||
created_at=1,
|
||||
completion_window="24h",
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id="file-input-1",
|
||||
object="batch",
|
||||
)
|
||||
router.aretrieve_batch = AsyncMock(return_value=mock_response)
|
||||
router.get_deployment_credentials_with_provider = MagicMock(
|
||||
|
|
@ -2280,8 +2284,8 @@ class TestUnmanagedBedrockRouting:
|
|||
mock_logging_obj.async_success_handler.assert_awaited_once()
|
||||
assert mock_logging_obj.async_success_handler.call_args[1]["batch_cost"] == 0.02
|
||||
|
||||
assert prisma.db.litellm_managedobjecttable.update.call_count == 1
|
||||
update_data = prisma.db.litellm_managedobjecttable.update.call_args[1]["data"]
|
||||
assert prisma.db.litellm_managedobjecttable.update_many.call_count == 2
|
||||
update_data = prisma.db.litellm_managedobjecttable.update_many.call_args[1]["data"]
|
||||
assert update_data["batch_processed"] is True
|
||||
assert update_data["status"] == "complete"
|
||||
|
||||
|
|
@ -2860,9 +2864,10 @@ class TestPollPageStarvation:
|
|||
await self._instance(prisma, llm_router).check_batch_cost()
|
||||
|
||||
llm_router.aretrieve_batch.assert_not_awaited()
|
||||
prisma.db.litellm_managedobjecttable.update.assert_awaited_once()
|
||||
call = prisma.db.litellm_managedobjecttable.update.call_args[1]
|
||||
assert call["where"] == {"id": "job-no-model"}
|
||||
prisma.db.litellm_managedobjecttable.update_many.assert_awaited()
|
||||
call = prisma.db.litellm_managedobjecttable.update_many.call_args[1]
|
||||
assert call["where"]["id"] == "job-no-model"
|
||||
assert call["where"]["batch_processed"] is False
|
||||
assert call["data"] == {"batch_processed": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -2890,10 +2895,8 @@ class TestPollPageStarvation:
|
|||
|
||||
await self._instance(prisma, llm_router).check_batch_cost()
|
||||
|
||||
prisma.db.litellm_managedobjecttable.update.assert_awaited_once()
|
||||
assert prisma.db.litellm_managedobjecttable.update.call_args[1]["data"] == {
|
||||
"batch_processed": True
|
||||
}
|
||||
prisma.db.litellm_managedobjecttable.update_many.assert_awaited()
|
||||
assert prisma.db.litellm_managedobjecttable.update_many.call_args[1]["data"] == {"batch_processed": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_404_with_deployment_gone_keeps_job(self):
|
||||
|
|
@ -2954,26 +2957,7 @@ class TestPollPageStarvation:
|
|||
|
||||
await instance.check_batch_cost()
|
||||
|
||||
assert prisma.db.litellm_managedobjecttable.update.call_args[1]["data"] == {
|
||||
"status": "stale_expired"
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_cleanup_gives_up_on_never_costed_completed_rows(self):
|
||||
"""A row already in a terminal status is never rewritten by the staleness sweep, so
|
||||
it needs its own bound or it starves newer batches indefinitely."""
|
||||
prisma = self._prisma([])
|
||||
|
||||
await self._instance(prisma, MagicMock()).check_batch_cost()
|
||||
|
||||
calls = prisma.db.litellm_managedobjecttable.update_many.call_args_list
|
||||
assert len(calls) == 2, "expected the staleness sweep plus the never-costed sweep"
|
||||
where = calls[1][1]["where"]
|
||||
assert where["file_purpose"] == "batch"
|
||||
assert where["batch_processed"] is False
|
||||
assert where["status"] == {"in": ["complete", "completed"]}
|
||||
assert "created_at" in where
|
||||
assert calls[1][1]["data"] == {"batch_processed": True}
|
||||
assert prisma.db.litellm_managedobjecttable.update_many.call_args[1]["data"] == {"status": "stale_expired"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_newer_batch_is_polled_once_dead_rows_are_retired(self):
|
||||
|
|
@ -3013,7 +2997,8 @@ class TestPollPageStarvation:
|
|||
|
||||
retired = [
|
||||
call[1]["where"]["id"]
|
||||
for call in prisma.db.litellm_managedobjecttable.update.call_args_list
|
||||
for call in prisma.db.litellm_managedobjecttable.update_many.call_args_list
|
||||
if "id" in call[1]["where"]
|
||||
]
|
||||
assert retired == ["job-no-model", "job-gone"]
|
||||
assert (
|
||||
|
|
@ -3061,7 +3046,8 @@ class _FakeManagedObjectRow:
|
|||
self.team_id = None
|
||||
self.api_key = None
|
||||
self.request_tags = None
|
||||
self.created_at = 1700000000
|
||||
self.created_at = datetime(9999, 1, 1, tzinfo=timezone.utc)
|
||||
self.updated_at = datetime(2000, 1, 1, tzinfo=timezone.utc)
|
||||
self.file_object = json.dumps(
|
||||
{"id": "batch-456", "status": "in_progress", "input_file_id": "file-input-1",
|
||||
"output_file_id": _CLAIM_OUTPUT_FILE_ID}
|
||||
|
|
@ -3087,7 +3073,9 @@ class _FakeManagedObjectTable:
|
|||
def _matches(self, where: dict) -> bool:
|
||||
for key, value in where.items():
|
||||
if key == "created_at":
|
||||
return False
|
||||
if "lt" in value and not self.row.created_at < value["lt"]:
|
||||
return False
|
||||
continue
|
||||
if key == "status":
|
||||
if self.row.status in value.get("not_in", []):
|
||||
return False
|
||||
|
|
@ -3100,7 +3088,9 @@ class _FakeManagedObjectTable:
|
|||
async def _update_many(self, *, where: dict, data: dict) -> int:
|
||||
if not self._matches(where):
|
||||
return 0
|
||||
if "batch_processed" in where:
|
||||
if "updated_at" in where:
|
||||
self.journal.append("finalize")
|
||||
elif "batch_processed" in where:
|
||||
self.journal.append("claim" if data.get("batch_processed") else "release")
|
||||
for key, value in data.items():
|
||||
setattr(self.row, key, value)
|
||||
|
|
@ -3149,13 +3139,20 @@ class TestMultiPodBatchCostClaim:
|
|||
|
||||
@staticmethod
|
||||
def _router():
|
||||
response = MagicMock()
|
||||
response.status = "completed"
|
||||
response.output_file_id = _CLAIM_OUTPUT_FILE_ID
|
||||
response.error_file_id = None
|
||||
response.created_at = 1
|
||||
response.completed_at = 2
|
||||
response.model_dump_json.return_value = '{"id":"batch-456","status":"completed"}'
|
||||
from litellm.types.utils import LiteLLMBatch
|
||||
|
||||
response: Final = LiteLLMBatch(
|
||||
id="batch-456",
|
||||
status="completed",
|
||||
output_file_id=_CLAIM_OUTPUT_FILE_ID,
|
||||
error_file_id=None,
|
||||
created_at=1,
|
||||
completed_at=2,
|
||||
completion_window="24h",
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id="file-input-1",
|
||||
object="batch",
|
||||
)
|
||||
|
||||
deployment = MagicMock()
|
||||
deployment.litellm_params.custom_llm_provider = "openai"
|
||||
|
|
@ -3226,6 +3223,351 @@ class TestMultiPodBatchCostClaim:
|
|||
):
|
||||
yield logging_obj
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failure_phase", ("before_write", "after_write", "external"))
|
||||
@pytest.mark.parametrize("collector_enabled", (False, True))
|
||||
async def test_builtin_accounting_retries_only_before_a_possible_charge(
|
||||
self, failure_phase: str, collector_enabled: bool
|
||||
) -> None:
|
||||
import base64
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
import litellm
|
||||
import respx
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
|
||||
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger as ProxyDBLogger
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
|
||||
from litellm.types.utils import SpecialEnums
|
||||
|
||||
class AccountingDependency:
|
||||
def __init__(self) -> None:
|
||||
self.failure_phase = failure_phase
|
||||
self.writer: Final = DBSpendUpdateWriter()
|
||||
self.failed_tracking_alert = AsyncMock()
|
||||
self.slack_alerting_instance = MagicMock()
|
||||
self.slack_alerting_instance.customer_spend_alert = AsyncMock(
|
||||
side_effect=ConnectionError("Alert destination unavailable after charge")
|
||||
if failure_phase == "after_write"
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def db_spend_update_writer(self) -> DBSpendUpdateWriter:
|
||||
if self.failure_phase == "before_write":
|
||||
raise ConnectionError("Accounting dependency temporarily unavailable")
|
||||
return self.writer
|
||||
|
||||
class ExternalCallback(CustomLogger):
|
||||
async def async_log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
||||
) -> None:
|
||||
if failure_phase == "external":
|
||||
raise ConnectionError("External destination unavailable")
|
||||
|
||||
budget_cache: Final = DualCache()
|
||||
RouterBudgetLimiting(
|
||||
dual_cache=budget_cache,
|
||||
provider_budget_config={"openai": {"budget_limit": 100, "time_period": "1d"}},
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "batch-alias",
|
||||
"litellm_params": {"model": "openai/batch-test-model", "max_budget": 100, "budget_duration": "1d"},
|
||||
"model_info": {"id": "model-123"},
|
||||
}
|
||||
],
|
||||
)
|
||||
dependency: Final = AccountingDependency()
|
||||
spend_prisma: Final = PrismaClient(
|
||||
database_url="postgresql://unused:unused@localhost:5432/unused", proxy_logging_obj=dependency
|
||||
)
|
||||
create_spend_rows: Final = AsyncMock(return_value=1)
|
||||
input_rate: Final = 0.000002
|
||||
litellm.register_model(
|
||||
{
|
||||
"batch-test-model": {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token_batches": input_rate,
|
||||
"output_cost_per_token_batches": 0.000006,
|
||||
}
|
||||
}
|
||||
)
|
||||
content: Final = json.dumps(
|
||||
{
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"body": {
|
||||
"model": "batch-test-model",
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 0, "total_tokens": 100},
|
||||
},
|
||||
},
|
||||
"error": None,
|
||||
}
|
||||
).encode()
|
||||
row: Final = _FakeManagedObjectRow()
|
||||
original_status: Final = row.status
|
||||
original_file_object: Final = row.file_object
|
||||
row.unified_object_id = (
|
||||
base64.urlsafe_b64encode(
|
||||
SpecialEnums.LITELLM_MANAGED_BATCH_COMPLETE_STR.value.format("model-123", "batch-456").encode()
|
||||
)
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
prisma: Final = self._prisma(row, [])
|
||||
router: Final = self._router()
|
||||
router.get_deployment.return_value = Deployment(
|
||||
model_name="batch-alias",
|
||||
litellm_params=LiteLLM_Params(model="openai/batch-test-model"),
|
||||
model_info=ModelInfo(id="model-123"),
|
||||
)
|
||||
from litellm.proxy.spend_tracking.spend_event_producer import SpendEventProducer
|
||||
|
||||
producer: Final = MagicMock(spec=SpendEventProducer) if collector_enabled else None
|
||||
litellm.logging_callback_manager.add_litellm_async_success_callback(
|
||||
ProxyDBLogger(spend_event_producer=producer)
|
||||
)
|
||||
litellm.logging_callback_manager.add_litellm_async_success_callback(ExternalCallback())
|
||||
with (
|
||||
respx.mock,
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj", dependency),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", spend_prisma),
|
||||
patch.object(
|
||||
spend_prisma.writer_db._original_prisma,
|
||||
"litellm_spendlogs",
|
||||
MagicMock(create_many=create_spend_rows),
|
||||
),
|
||||
):
|
||||
respx.get(f"https://api.openai.com/v1/files/{_CLAIM_OUTPUT_FILE_ID}/content").mock(
|
||||
return_value=httpx.Response(200, content=content)
|
||||
)
|
||||
worker: Final = self._instance(prisma, router)
|
||||
await worker.check_batch_cost()
|
||||
assert row.batch_processed is (failure_phase != "before_write")
|
||||
if failure_phase == "before_write":
|
||||
assert row.status == original_status
|
||||
assert json.loads(row.file_object) == original_file_object
|
||||
assert create_spend_rows.await_count == (0 if failure_phase == "before_write" else 1)
|
||||
dependency.failure_phase = "recovered"
|
||||
await worker.check_batch_cost()
|
||||
await worker.check_batch_cost()
|
||||
|
||||
assert row.batch_processed is True
|
||||
assert await budget_cache.async_get_cache("provider_spend:openai:1d") == pytest.approx(100 * input_rate)
|
||||
assert await budget_cache.async_get_cache("deployment_spend:model-123:1d") == pytest.approx(100 * input_rate)
|
||||
create_spend_rows.assert_awaited_once()
|
||||
assert create_spend_rows.call_args.kwargs["data"][0]["spend"] == pytest.approx(100 * input_rate)
|
||||
if producer is not None:
|
||||
assert all(
|
||||
json.loads(call.args[0])["call_type"] != "aretrieve_batch" for call in producer.publish.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("deployment_model", "successes", "failures", "expected_model"),
|
||||
(
|
||||
("openai/", 0, 2, "openai/"),
|
||||
("", 0, 2, "model-123"),
|
||||
("openai/", 2, 1, "batch-test-model"),
|
||||
("openai/batch-test-model", 2, 1, "batch-test-model"),
|
||||
("openai/batch-test-model", 2, 0, "batch-test-model"),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize("failure_envelope", (None, 1, "invalid", [1]))
|
||||
async def test_batch_accounting_preserves_models_and_mixed_results(
|
||||
self, deployment_model: str, successes: int, failures: int, expected_model: str, failure_envelope: object
|
||||
) -> None:
|
||||
import base64
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
import litellm
|
||||
import respx
|
||||
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
|
||||
from litellm.types.utils import LiteLLMBatch, SpecialEnums
|
||||
|
||||
input_rate: Final = 0.000002
|
||||
output_rate: Final = 0.000006
|
||||
litellm.register_model(
|
||||
{
|
||||
"batch-test-model": {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token_batches": input_rate,
|
||||
"output_cost_per_token_batches": output_rate,
|
||||
}
|
||||
}
|
||||
)
|
||||
success: Final = {
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"body": {
|
||||
"model": "batch-test-model",
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120},
|
||||
},
|
||||
},
|
||||
"error": None,
|
||||
}
|
||||
failure: Final = {"response": failure_envelope, "error": {"code": "batch_expired", "message": "expired"}}
|
||||
rows: Final = (success,) * successes + (failure,) * failures
|
||||
content: Final = b"\n".join(json.dumps(row).encode() for row in rows)
|
||||
|
||||
class BatchSpendRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.calls: tuple[dict[str, object], ...] = ()
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
||||
) -> None:
|
||||
if kwargs["call_type"] == "aretrieve_batch":
|
||||
self.calls += (kwargs,)
|
||||
|
||||
callback: Final = BatchSpendRecorder()
|
||||
litellm.logging_callback_manager.add_litellm_async_success_callback(callback)
|
||||
row: Final = _FakeManagedObjectRow()
|
||||
row.unified_object_id = (
|
||||
base64.urlsafe_b64encode(
|
||||
SpecialEnums.LITELLM_MANAGED_BATCH_COMPLETE_STR.value.format("model-123", "batch-456").encode()
|
||||
)
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
prisma: Final = self._prisma(row, [])
|
||||
router: Final = self._router()
|
||||
router.get_deployment.return_value = Deployment(
|
||||
model_name="batch-alias",
|
||||
litellm_params=LiteLLM_Params(model=deployment_model, custom_llm_provider="openai"),
|
||||
model_info=ModelInfo(id="model-123"),
|
||||
)
|
||||
router.aretrieve_batch.return_value = LiteLLMBatch(
|
||||
id="batch-456",
|
||||
completion_window="24h",
|
||||
created_at=1,
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id="file-input-1",
|
||||
output_file_id=_CLAIM_OUTPUT_FILE_ID,
|
||||
object="batch",
|
||||
status="completed",
|
||||
)
|
||||
router.get_deployment_credentials_with_provider.return_value = {
|
||||
"api_key": "synthetic-key",
|
||||
"custom_llm_provider": "openai",
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get(f"https://api.openai.com/v1/files/{_CLAIM_OUTPUT_FILE_ID}/content").mock(
|
||||
return_value=httpx.Response(200, content=content)
|
||||
)
|
||||
instance: Final = self._instance(prisma, router)
|
||||
await instance.check_batch_cost()
|
||||
await instance.check_batch_cost()
|
||||
|
||||
assert row.batch_processed is True
|
||||
assert row.status == "complete"
|
||||
router.aretrieve_batch.assert_awaited_once()
|
||||
assert len(callback.calls) == 1
|
||||
payload: Final = callback.calls[0]["standard_logging_object"]
|
||||
assert payload["model"] == expected_model
|
||||
assert payload["response_cost"] == pytest.approx(successes * (100 * input_rate + 20 * output_rate))
|
||||
assert payload["prompt_tokens"] == successes * 100
|
||||
assert payload["completion_tokens"] == successes * 20
|
||||
assert payload["hidden_params"]["batch_successful_requests"] == successes
|
||||
assert payload["hidden_params"]["batch_failed_requests"] == failures
|
||||
assert payload["hidden_params"]["batch_models"] == (
|
||||
["batch-test-model"] if deployment_model == "openai/batch-test-model" else ["batch-test-model"] * successes
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("error_source", ("batch", "output"))
|
||||
async def test_a_404_for_another_identifier_preserves_recovery(self, error_source: str) -> None:
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import litellm
|
||||
import respx
|
||||
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.types.utils import SpecialEnums
|
||||
|
||||
class SpendRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.costs: tuple[float, ...] = ()
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
||||
) -> None:
|
||||
if kwargs["call_type"] == "aretrieve_batch":
|
||||
cost: Final = kwargs["response_cost"]
|
||||
assert isinstance(cost, float)
|
||||
self.costs += (cost,)
|
||||
|
||||
recorder: Final = SpendRecorder()
|
||||
litellm.logging_callback_manager.add_litellm_async_success_callback(recorder)
|
||||
litellm.register_model(
|
||||
{
|
||||
"batch-recovery-model": {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token_batches": 0.01,
|
||||
"output_cost_per_token_batches": 0.02,
|
||||
}
|
||||
}
|
||||
)
|
||||
row: Final = _FakeManagedObjectRow()
|
||||
row.unified_object_id = (
|
||||
base64.urlsafe_b64encode(
|
||||
SpecialEnums.LITELLM_MANAGED_BATCH_COMPLETE_STR.value.format("model-123", "batch-456").encode()
|
||||
)
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
router: Final = self._router()
|
||||
response: Final = router.aretrieve_batch.return_value
|
||||
error: Final = litellm.NotFoundError(
|
||||
message=f"No object found with id '{'batch-456' if error_source == 'batch' else _CLAIM_OUTPUT_FILE_ID}0'.",
|
||||
model="gpt-4",
|
||||
llm_provider="openai",
|
||||
)
|
||||
router.aretrieve_batch.side_effect = (error, response) if error_source == "batch" else (response, response)
|
||||
prisma: Final = self._prisma(row, [])
|
||||
output: Final = json.dumps(
|
||||
{
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"body": {
|
||||
"model": "batch-recovery-model",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
},
|
||||
},
|
||||
"error": None,
|
||||
}
|
||||
).encode()
|
||||
with respx.mock:
|
||||
route: Final = respx.get(f"https://api.openai.com/v1/files/{_CLAIM_OUTPUT_FILE_ID}/content")
|
||||
route.mock(
|
||||
side_effect=error if error_source == "output" else None,
|
||||
return_value=None if error_source == "output" else httpx.Response(200, content=output),
|
||||
)
|
||||
await self._instance(prisma, router).check_batch_cost()
|
||||
assert row.batch_processed is False
|
||||
assert recorder.costs == ()
|
||||
route.mock(return_value=httpx.Response(200, content=output))
|
||||
await self._instance(prisma, router).check_batch_cost()
|
||||
await self._instance(prisma, router).check_batch_cost()
|
||||
|
||||
assert row.batch_processed is True
|
||||
assert row.status == "complete"
|
||||
assert recorder.costs == (pytest.approx(10 * 0.01 + 5 * 0.02),)
|
||||
|
||||
@staticmethod
|
||||
def _claim_calls(prisma) -> list:
|
||||
return [
|
||||
|
|
@ -3249,6 +3591,30 @@ class TestMultiPodBatchCostClaim:
|
|||
with patch("litellm.proxy.proxy_server.scheduler", scheduler):
|
||||
await guard._check_file_deletion_allowed(file_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", ("completed", "failed", "expired", "cancelled"))
|
||||
@pytest.mark.parametrize("metadata_text", ("before\x00after", r"\u0000"))
|
||||
async def test_cost_claim_persists_terminal_metadata_without_a_later_write(
|
||||
self, status: str, metadata_text: str
|
||||
) -> None:
|
||||
row: Final = _FakeManagedObjectRow()
|
||||
journal: Final = []
|
||||
prisma: Final = self._prisma(row, journal)
|
||||
router: Final = self._router()
|
||||
router.aretrieve_batch.return_value = router.aretrieve_batch.return_value.model_copy(
|
||||
update={"status": status, "metadata": {"annotation": metadata_text}}
|
||||
)
|
||||
prisma.db.litellm_managedobjecttable.update.side_effect = RuntimeError("final write unavailable")
|
||||
worker: Final = self._instance(prisma, router)
|
||||
with self._billing_patches(journal) as logging_obj:
|
||||
await worker.check_batch_cost()
|
||||
await worker.check_batch_cost()
|
||||
assert row.batch_processed is True
|
||||
assert row.status == ("complete" if status == "completed" else status)
|
||||
assert json.loads(row.file_object)["status"] == status
|
||||
assert json.loads(row.file_object)["metadata"] == {"annotation": metadata_text.replace("\x00", "")}
|
||||
assert logging_obj.async_success_handler.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_winning_pod_claims_the_row_between_fetching_and_billing(self):
|
||||
"""The claim flips batch_processed false -> true after the results are in hand and
|
||||
|
|
@ -3260,13 +3626,9 @@ class TestMultiPodBatchCostClaim:
|
|||
with self._billing_patches(journal) as logging_obj:
|
||||
await self._instance(prisma, self._router()).check_batch_cost()
|
||||
|
||||
assert journal == ["fetch", "claim", "bill", "finalize"]
|
||||
assert self._claim_calls(prisma) == [
|
||||
{
|
||||
"where": {"id": "job-claim-1", "batch_processed": False},
|
||||
"data": {"batch_processed": True},
|
||||
}
|
||||
]
|
||||
assert journal == ["fetch", "claim", "bill"]
|
||||
assert row.status == "complete"
|
||||
assert json.loads(row.file_object)["usage"] == {"prompt_tokens": 10, "completion_tokens": 5}
|
||||
logging_obj.async_success_handler.assert_awaited_once()
|
||||
assert row.batch_processed is True
|
||||
|
||||
|
|
@ -3286,30 +3648,28 @@ class TestMultiPodBatchCostClaim:
|
|||
|
||||
assert journal == ["fetch"]
|
||||
logging_obj.async_success_handler.assert_not_awaited()
|
||||
assert self._claim_calls(prisma) == [
|
||||
{
|
||||
"where": {"id": "job-claim-1", "batch_processed": False},
|
||||
"data": {"batch_processed": True},
|
||||
}
|
||||
]
|
||||
assert row.status == "in_progress"
|
||||
assert json.loads(row.file_object)["status"] == "in_progress"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failed_spend_log_write_releases_the_claim(self):
|
||||
"""A transient failure while billing a claimed batch must hand the row back, or its
|
||||
spend is silently lost instead of being retried on the next cycle."""
|
||||
row = _FakeManagedObjectRow()
|
||||
journal = []
|
||||
prisma = self._prisma(row, journal)
|
||||
@pytest.mark.parametrize("stored_as_json_string", (True, False))
|
||||
async def test_an_unclassified_logging_failure_keeps_the_claim(self, stored_as_json_string: bool) -> None:
|
||||
row: Final = _FakeManagedObjectRow()
|
||||
row.file_object = row.file_object if stored_as_json_string else json.loads(row.file_object)
|
||||
journal: Final = []
|
||||
prisma: Final = self._prisma(row, journal)
|
||||
|
||||
with self._billing_patches(journal, bill_error=Exception("spend log write failed")):
|
||||
with self._billing_patches(journal, bill_error=Exception("Accounting outcome unknown")):
|
||||
await self._instance(prisma, self._router()).check_batch_cost()
|
||||
|
||||
assert journal == ["fetch", "claim", "bill", "release"]
|
||||
assert row.batch_processed is False
|
||||
assert self._claim_calls(prisma)[-1] == {
|
||||
"where": {"id": "job-claim-1", "batch_processed": True},
|
||||
"data": {"batch_processed": False},
|
||||
}
|
||||
assert journal == ["fetch", "claim", "bill"]
|
||||
assert row.batch_processed is True
|
||||
|
||||
with self._billing_patches(journal) as logging_obj:
|
||||
await self._instance(prisma, self._router()).check_batch_cost()
|
||||
await self._instance(prisma, self._router()).check_batch_cost()
|
||||
|
||||
logging_obj.async_success_handler.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_worker_interrupted_mid_costing_leaves_the_batch_billable(self):
|
||||
|
|
@ -3343,7 +3703,7 @@ class TestMultiPodBatchCostClaim:
|
|||
with self._billing_patches(survivor_journal) as survivor_logging:
|
||||
await self._instance(survivor_prisma, self._router()).check_batch_cost()
|
||||
|
||||
assert survivor_journal == ["fetch", "claim", "bill", "finalize"]
|
||||
assert survivor_journal == ["fetch", "claim", "bill"]
|
||||
survivor_logging.async_success_handler.assert_awaited_once()
|
||||
assert row.batch_processed is True
|
||||
|
||||
|
|
@ -3376,7 +3736,7 @@ class TestMultiPodBatchCostClaim:
|
|||
finish_fetch.set()
|
||||
await asyncio.wait_for(costing, timeout=5)
|
||||
|
||||
assert journal == ["fetch", "claim", "bill", "finalize"]
|
||||
assert journal == ["fetch", "claim", "bill"]
|
||||
assert row.batch_processed is True
|
||||
await self._run_deletion_guard(prisma, _CLAIM_OUTPUT_FILE_ID)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -3027,3 +3028,75 @@ async def test_track_cost_callback_frees_grounding_under_the_allowance_only_when
|
|||
else:
|
||||
assert kwargs["response_cost"] == 1.0 and "discount_amount" not in breakdown
|
||||
assert counter.totals == {}
|
||||
|
||||
|
||||
_CALL_START: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_accounting_failures_stay_with_their_originating_call() -> None:
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import track_batch_accounting
|
||||
|
||||
logger: Final = _ProxyDBLogger()
|
||||
dependency: Final = SimpleNamespace(failed_tracking_alert=AsyncMock())
|
||||
|
||||
async def fail_before_accounting(call_id: str) -> None:
|
||||
await logger.async_log_success_event(
|
||||
kwargs={
|
||||
"litellm_call_id": call_id,
|
||||
"call_type": "aretrieve_batch",
|
||||
"model": "batch-model",
|
||||
"stream": False,
|
||||
"response_cost": None,
|
||||
},
|
||||
response_obj=None,
|
||||
start_time=_CALL_START,
|
||||
end_time=_CALL_START,
|
||||
)
|
||||
|
||||
async def independent_batch() -> bool:
|
||||
with track_batch_accounting("independent") as outcome:
|
||||
await fail_before_accounting("independent")
|
||||
return outcome.retryable_failure
|
||||
|
||||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", dependency):
|
||||
with track_batch_accounting("outer") as outer:
|
||||
await fail_before_accounting("unrelated")
|
||||
assert outer.retryable_failure is False
|
||||
with track_batch_accounting("inner") as inner:
|
||||
await fail_before_accounting("inner")
|
||||
assert inner.retryable_failure is True
|
||||
assert outer.retryable_failure is False
|
||||
independent, _ = await asyncio.gather(independent_batch(), fail_before_accounting("outer"))
|
||||
assert independent is True
|
||||
assert outer.retryable_failure is True
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
with track_batch_accounting("cancelled") as cancelled:
|
||||
raise asyncio.CancelledError
|
||||
await fail_before_accounting("cancelled")
|
||||
assert cancelled.retryable_failure is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_accounting_does_not_retry_after_settling_a_budget_reservation() -> None:
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import track_batch_accounting
|
||||
|
||||
reservation: Final[dict[str, object]] = {"reserved_cost": 0.5, "entries": ()}
|
||||
dependency: Final = SimpleNamespace(failed_tracking_alert=AsyncMock())
|
||||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", dependency):
|
||||
with track_batch_accounting("reserved") as outcome:
|
||||
await _ProxyDBLogger().async_log_success_event(
|
||||
kwargs={
|
||||
"litellm_call_id": "reserved",
|
||||
"call_type": "aretrieve_batch",
|
||||
"model": "batch-model",
|
||||
"stream": False,
|
||||
"response_cost": None,
|
||||
"litellm_params": {"metadata": {"user_api_key_budget_reservation": reservation}},
|
||||
},
|
||||
response_obj=None,
|
||||
start_time=_CALL_START,
|
||||
end_time=_CALL_START,
|
||||
)
|
||||
assert reservation["finalized"] is True
|
||||
assert outcome.retryable_failure is False
|
||||
|
|
|
|||
|
|
@ -4382,11 +4382,11 @@ class TestEnsureOutputItemContentPartAdded:
|
|||
namespace_map.assert_called_once_with(request["tools"])
|
||||
|
||||
|
||||
def test_emit_response_completed_uses_stream_finish_reason(self):
|
||||
def test_emit_terminal_event_uses_stream_finish_reason(self):
|
||||
"""
|
||||
When the assembled model response carries finish_reason="content_filter"
|
||||
(snapshotted from the underlying stream before any pending events fire),
|
||||
_emit_response_completed_event must produce status="incomplete".
|
||||
The terminal stream event type must track the assembled response
|
||||
status: finish_reason="content_filter" yields response.incomplete,
|
||||
finish_reason="stop" yields response.completed.
|
||||
"""
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
|
@ -4394,6 +4394,7 @@ class TestEnsureOutputItemContentPartAdded:
|
|||
from litellm.responses.litellm_completion_transformation.streaming_iterator import (
|
||||
LiteLLMCompletionStreamingIterator,
|
||||
)
|
||||
from litellm.types.llms.openai import ResponsesAPIStreamEvents
|
||||
|
||||
mock_stream_wrapper = Mock(spec=litellm.CustomStreamWrapper)
|
||||
mock_stream_wrapper.logging_obj = Mock()
|
||||
|
|
@ -4406,28 +4407,32 @@ class TestEnsureOutputItemContentPartAdded:
|
|||
custom_llm_provider="anthropic",
|
||||
)
|
||||
|
||||
litellm_model_response = ModelResponse(
|
||||
id="chatcmpl-test",
|
||||
created=1234567890,
|
||||
model="anthropic/claude-sonnet-4-6",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="content_filter",
|
||||
index=0,
|
||||
message=Message(content="", role="assistant"),
|
||||
)
|
||||
],
|
||||
usage=Usage(prompt_tokens=10, completion_tokens=1, total_tokens=11),
|
||||
)
|
||||
def _response(finish_reason: str) -> ModelResponse:
|
||||
return ModelResponse(
|
||||
id="chatcmpl-test",
|
||||
created=1234567890,
|
||||
model="anthropic/claude-sonnet-4-6",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason=finish_reason,
|
||||
index=0,
|
||||
message=Message(content="", role="assistant"),
|
||||
)
|
||||
],
|
||||
usage=Usage(prompt_tokens=10, completion_tokens=1, total_tokens=11),
|
||||
)
|
||||
|
||||
completed_event = iterator._emit_response_completed_event(
|
||||
litellm_model_response
|
||||
)
|
||||
incomplete_event = iterator._emit_terminal_response_event(_response("content_filter"))
|
||||
assert incomplete_event is not None
|
||||
assert incomplete_event.type == ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE
|
||||
assert incomplete_event.response.status == "incomplete"
|
||||
assert incomplete_event.response.output[0].status == "incomplete"
|
||||
|
||||
completed_event = iterator._emit_terminal_response_event(_response("stop"))
|
||||
assert completed_event is not None
|
||||
assert completed_event.response.status == "incomplete"
|
||||
assert completed_event.response.output[0].status == "incomplete"
|
||||
assert completed_event.type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED
|
||||
assert completed_event.response.status == "completed"
|
||||
|
||||
def test_reasoning_item_does_not_emit_content_part_added(self):
|
||||
"""Reasoning items should not get a content_part.added event."""
|
||||
|
|
@ -5016,7 +5021,7 @@ class TestStreamingSnapshotItemIds:
|
|||
)
|
||||
assert streamed_event is not None
|
||||
|
||||
completed_event = iterator._emit_response_completed_event(
|
||||
completed_event = iterator._emit_terminal_response_event(
|
||||
_bridged_chat_completion_response()
|
||||
)
|
||||
|
||||
|
|
@ -5031,7 +5036,7 @@ class TestStreamingSnapshotItemIds:
|
|||
self._make_chunk("apple")
|
||||
)
|
||||
|
||||
completed_event = iterator._emit_response_completed_event(
|
||||
completed_event = iterator._emit_terminal_response_event(
|
||||
_bridged_chat_completion_response()
|
||||
)
|
||||
|
||||
|
|
@ -5088,7 +5093,7 @@ class TestStreamingSnapshotItemIds:
|
|||
)
|
||||
assert streamed_event is not None
|
||||
|
||||
completed_event = iterator._emit_response_completed_event(
|
||||
completed_event = iterator._emit_terminal_response_event(
|
||||
self._reasoning_chat_completion_response()
|
||||
)
|
||||
|
||||
|
|
@ -5159,6 +5164,140 @@ def test_transform_chat_completion_response_incomplete_details():
|
|||
assert result_existing.incomplete_details == existing_details
|
||||
|
||||
|
||||
def test_transform_chat_completion_response_null_finish_reason_is_incomplete():
|
||||
"""
|
||||
A choice with no finish signal (e.g. Gemini thinking consuming the whole
|
||||
output budget, or a provider that never sent one) must not report
|
||||
status "completed". Output at the requested cap reads as
|
||||
max_output_tokens; below it, no reason can be claimed.
|
||||
"""
|
||||
choice_at_cap = Choices.model_construct(
|
||||
index=0,
|
||||
finish_reason=None,
|
||||
message=Message(content="", role="assistant"),
|
||||
)
|
||||
resp_at_cap = ModelResponse(
|
||||
id="resp-null-cap",
|
||||
choices=[choice_at_cap],
|
||||
model="gemini-2.5-pro",
|
||||
usage=Usage(prompt_tokens=10, completion_tokens=60, total_tokens=70),
|
||||
)
|
||||
result_at_cap = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
||||
request_input="test prompt",
|
||||
responses_api_request={"max_output_tokens": 60},
|
||||
chat_completion_response=resp_at_cap,
|
||||
)
|
||||
assert result_at_cap.status == "incomplete"
|
||||
assert result_at_cap.incomplete_details is not None
|
||||
assert result_at_cap.incomplete_details.reason == "max_output_tokens"
|
||||
|
||||
choice_under_cap = Choices.model_construct(
|
||||
index=0,
|
||||
finish_reason=None,
|
||||
message=Message(content="", role="assistant"),
|
||||
)
|
||||
resp_under_cap = ModelResponse(
|
||||
id="resp-null-under",
|
||||
choices=[choice_under_cap],
|
||||
model="gemini-2.5-pro",
|
||||
usage=Usage(prompt_tokens=10, completion_tokens=30, total_tokens=40),
|
||||
)
|
||||
result_under_cap = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
||||
request_input="test prompt",
|
||||
responses_api_request={"max_output_tokens": 60},
|
||||
chat_completion_response=resp_under_cap,
|
||||
)
|
||||
assert result_under_cap.status == "incomplete"
|
||||
assert result_under_cap.incomplete_details is None
|
||||
|
||||
resp_empty = ModelResponse(id="resp-empty", choices=[], model="gemini-2.5-pro")
|
||||
result_empty = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
||||
request_input="test prompt",
|
||||
responses_api_request={},
|
||||
chat_completion_response=resp_empty,
|
||||
)
|
||||
assert result_empty.status == "incomplete"
|
||||
|
||||
|
||||
def test_transform_chat_completion_response_echoes_request_params():
|
||||
"""
|
||||
A chat completion response never carries the caller's request fields, so
|
||||
the bridged response echoes them from the request. Before this fix
|
||||
temperature reported 0 and the rest reported None or empty defaults.
|
||||
"""
|
||||
resp = ModelResponse(
|
||||
id="resp-echo",
|
||||
choices=[Choices(index=0, finish_reason="stop", message=Message(content="hi", role="assistant"))],
|
||||
model="gpt-4o",
|
||||
)
|
||||
result = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
||||
request_input="test prompt",
|
||||
responses_api_request={
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"max_output_tokens": 128,
|
||||
"instructions": "be terse",
|
||||
"metadata": {"trace": "abc"},
|
||||
"user": "user-123",
|
||||
"truncation": "disabled",
|
||||
"store": True,
|
||||
"parallel_tool_calls": True,
|
||||
},
|
||||
chat_completion_response=resp,
|
||||
)
|
||||
assert result.temperature == 0.7
|
||||
assert result.top_p == 0.9
|
||||
assert result.max_output_tokens == 128
|
||||
assert result.instructions == "be terse"
|
||||
assert result.metadata == {"trace": "abc"}
|
||||
assert result.user == "user-123"
|
||||
assert result.truncation == "disabled"
|
||||
assert result.store is True
|
||||
assert result.parallel_tool_calls is True
|
||||
|
||||
# Fields the request never set fall back to the response defaults.
|
||||
result_unset = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
||||
request_input="test prompt",
|
||||
responses_api_request={},
|
||||
chat_completion_response=resp,
|
||||
)
|
||||
assert result_unset.temperature is None
|
||||
assert result_unset.metadata == {}
|
||||
assert result_unset.parallel_tool_calls is False
|
||||
assert result_unset.tools == []
|
||||
assert result_unset.text == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "invalid_value", "expected"),
|
||||
[
|
||||
("user", 123, None),
|
||||
("instructions", 5, None),
|
||||
("text", "plain", {}),
|
||||
("truncation", "bogus", None),
|
||||
("parallel_tool_calls", "maybe", False),
|
||||
],
|
||||
)
|
||||
def test_transform_drops_request_params_the_response_cannot_echo(
|
||||
field: str, invalid_value: object, expected: object
|
||||
) -> None:
|
||||
request: Final[dict[str, object]] = {"temperature": 0.7, field: invalid_value}
|
||||
response: Final = ModelResponse(
|
||||
id="resp-invalid-echo",
|
||||
choices=[Choices(index=0, finish_reason="stop", message=Message(content="hi", role="assistant"))],
|
||||
model="gpt-4o",
|
||||
)
|
||||
|
||||
result: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
||||
request_input="test prompt",
|
||||
responses_api_request=request,
|
||||
chat_completion_response=response,
|
||||
)
|
||||
|
||||
assert getattr(result, field) == expected, f"invalid {field} value {invalid_value!r}"
|
||||
assert result.temperature == 0.7, f"valid temperature was dropped with invalid {field}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [True, False])
|
||||
async def test_bridge_rejects_untranslatable_tool_choice_with_a_400(stream: bool):
|
||||
with pytest.raises(litellm.BadRequestError) as exc_info:
|
||||
|
|
|
|||
|
|
@ -356,11 +356,8 @@ class TestUseResponsesApiBridgeFlag:
|
|||
assert upstream.call_count == 1
|
||||
request_body: Final = json.loads(upstream.calls[0].request.read())
|
||||
assert request_body["messages"] == [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "You are terse."}, {"type": "text", "text": "Skills: none."}],
|
||||
},
|
||||
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
|
||||
{"role": "system", "content": "You are terse.\n\nSkills: none."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
assert "client_metadata" not in request_body
|
||||
assert request_body["chat_template_kwargs"] == {"thinking": True}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Tests that:
|
|||
"""
|
||||
|
||||
import json
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -283,6 +284,91 @@ class TestManagedWebSocketHandlerIntegration:
|
|||
|
||||
assert captured["model"] == "bedrock_mantle/openai.gpt-5.5"
|
||||
|
||||
@pytest.mark.parametrize("terminal_type", ["response.completed", "response.incomplete"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncated_turn_is_kept_as_history_for_the_next_turn(self, monkeypatch, terminal_type: str):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.responses.streaming_iterator import ManagedResponsesWebSocketHandler
|
||||
|
||||
terminal_response: Final = {
|
||||
"type": terminal_type,
|
||||
"response": {
|
||||
"id": "resp_turn1",
|
||||
"status": "incomplete" if terminal_type == "response.incomplete" else "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "PELICAN noted"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
async def first_stream():
|
||||
yield terminal_response
|
||||
|
||||
async def empty_stream():
|
||||
return
|
||||
yield {}
|
||||
|
||||
aresponses_mock: Final = AsyncMock(side_effect=(first_stream(), empty_stream()))
|
||||
monkeypatch.setattr(litellm, "aresponses", aresponses_mock)
|
||||
|
||||
websocket: Final = MagicMock()
|
||||
websocket.send_text = AsyncMock()
|
||||
handler: Final = ManagedResponsesWebSocketHandler(
|
||||
websocket=websocket,
|
||||
model="bedrock_mantle/openai.gpt-5.5",
|
||||
logging_obj=Logging(
|
||||
model="bedrock_mantle/openai.gpt-5.5",
|
||||
messages=[],
|
||||
stream=True,
|
||||
call_type="aresponses",
|
||||
start_time=0,
|
||||
litellm_call_id="test-id",
|
||||
function_id="test-func",
|
||||
),
|
||||
litellm_metadata={"model_group": "gpt-5.5-mantle"},
|
||||
)
|
||||
|
||||
turn_one_user: Final[dict[str, object]] = {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "My secret word is PELICAN"}],
|
||||
}
|
||||
turn_two_user: Final[dict[str, object]] = {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "What is my secret word?"}],
|
||||
}
|
||||
turn_one_frame: Final = json.dumps({"type": "response.create", "input": [turn_one_user]})
|
||||
turn_two_frame: Final = json.dumps(
|
||||
{
|
||||
"type": "response.create",
|
||||
"previous_response_id": "resp_turn1",
|
||||
"input": [turn_two_user],
|
||||
}
|
||||
)
|
||||
|
||||
await handler._process_response_create(turn_one_frame)
|
||||
await handler._process_response_create(turn_two_frame)
|
||||
|
||||
expected_input: Final[list[dict[str, object]]] = [
|
||||
turn_one_user,
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "PELICAN noted"}],
|
||||
},
|
||||
turn_two_user,
|
||||
]
|
||||
second_call_input: Final = aresponses_mock.call_args_list[1].kwargs["input"]
|
||||
assert second_call_input == expected_input, terminal_type
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warmup_frame_skips_provider_and_sends_synthetic_ack(
|
||||
self, monkeypatch
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ anthropic_messages, embedding and rerank surfaces leave it unset, which used to
|
|||
the callback raise before any spend was recorded, so those budgets never moved.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
|
||||
|
||||
|
|
@ -135,3 +138,91 @@ async def test_deployment_budget_tracked_when_provider_is_unresolvable(disable_b
|
|||
)
|
||||
|
||||
assert await limiter.dual_cache.async_get_cache("deployment_spend:deployment-1:1d") == 0.25
|
||||
|
||||
|
||||
def _completed_budget_batch(batch_id: str):
|
||||
from litellm.types.utils import LiteLLMBatch
|
||||
|
||||
return LiteLLMBatch(
|
||||
id=batch_id,
|
||||
completion_window="24h",
|
||||
created_at=1,
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id="file-budget",
|
||||
object="batch",
|
||||
status="completed",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("call_type", ("aretrieve_batch", "retrieve_batch"))
|
||||
async def test_batch_polls_charge_each_router_budget_once(monkeypatch, call_type):
|
||||
monkeypatch.setattr(litellm, "tag_budget_config", {"release": {"budget_limit": 100, "time_period": "1d"}})
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
|
||||
cache: Final = DualCache()
|
||||
limiter: Final = RouterBudgetLimiting(
|
||||
dual_cache=cache,
|
||||
provider_budget_config={"openai": {"budget_limit": 100, "time_period": "1d"}},
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "batch-budget",
|
||||
"litellm_params": {"model": "openai/batch-budget", "max_budget": 100, "budget_duration": "1d"},
|
||||
"model_info": {"id": "deployment-1"},
|
||||
}
|
||||
],
|
||||
)
|
||||
kwargs: Final = {
|
||||
**_success_kwargs(provider_in_litellm_params=None, provider_in_payload="openai", call_type=call_type),
|
||||
"metadata": {"tags": ["release"]},
|
||||
}
|
||||
response: Final = _completed_budget_batch("batch-first")
|
||||
await asyncio.gather(*(limiter.async_log_success_event(kwargs, response, None, None) for _ in range(10)))
|
||||
keys: Final = ("provider_spend:openai:1d", "deployment_spend:deployment-1:1d", "tag_spend:release:1d")
|
||||
assert tuple([await cache.async_get_cache(key) for key in keys]) == (0.25, 0.25, 0.25)
|
||||
await limiter.async_log_success_event(kwargs, _completed_budget_batch("batch-second"), None, None)
|
||||
await limiter.async_log_success_event({**kwargs, "call_type": "acompletion"}, response, None, None)
|
||||
await limiter.async_log_success_event({**kwargs, "call_type": "acompletion"}, response, None, None)
|
||||
assert tuple([await cache.async_get_cache(key) for key in keys]) == (1.0, 1.0, 1.0)
|
||||
await limiter.async_log_success_event({**kwargs, "call_type": "_arealtime"}, response, None, None)
|
||||
assert tuple([await cache.async_get_cache(key) for key in keys]) == (1.0, 1.0, 1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_charge_is_shared_across_workers_and_requires_redis_confirmation():
|
||||
from fakeredis import FakeServer, FakeRedis as FakeSyncRedis
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
from fakeredis.aioredis import FakeRedis
|
||||
from litellm.caching.redis_cache import RedisCache
|
||||
|
||||
server: Final = FakeServer()
|
||||
client: Final = FakeRedis(server=server)
|
||||
sync_client: Final = FakeSyncRedis(server=server)
|
||||
redis_cache: Final = RedisCache(host="router-batch-fixture", connection_pool=sync_client.connection_pool)
|
||||
litellm.in_memory_llm_clients_cache.set_cache(key=redis_cache._get_async_client_cache_key(), value=client)
|
||||
first: Final = RouterBudgetLimiting(
|
||||
dual_cache=DualCache(redis_cache=redis_cache),
|
||||
provider_budget_config={"openai": {"budget_limit": 100, "time_period": "1d"}},
|
||||
)
|
||||
second: Final = RouterBudgetLimiting(
|
||||
dual_cache=DualCache(redis_cache=redis_cache),
|
||||
provider_budget_config={"openai": {"budget_limit": 100, "time_period": "1d"}},
|
||||
)
|
||||
kwargs: Final = _success_kwargs(
|
||||
provider_in_litellm_params=None, provider_in_payload="openai", call_type="aretrieve_batch"
|
||||
)
|
||||
response: Final = _completed_budget_batch("batch-shared")
|
||||
await asyncio.gather(
|
||||
first.async_log_success_event(kwargs, response, None, None),
|
||||
second.async_log_success_event(kwargs, response, None, None),
|
||||
)
|
||||
await first._push_in_memory_increments_to_redis()
|
||||
await second._push_in_memory_increments_to_redis()
|
||||
assert float(await client.get("provider_spend:openai:1d")) == 0.25
|
||||
server.connected = False
|
||||
with pytest.raises(RedisConnectionError):
|
||||
await second.async_log_success_event(kwargs, _completed_budget_batch("batch-unconfirmed"), None, None)
|
||||
server.connected = True
|
||||
await second.async_log_success_event(kwargs, _completed_budget_batch("batch-unconfirmed"), None, None)
|
||||
await second._push_in_memory_increments_to_redis()
|
||||
assert float(await client.get("provider_spend:openai:1d")) == 0.5
|
||||
await client.aclose()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
128
tests/unit/test_claude_haiku_5_5_config.py
Normal file
128
tests/unit/test_claude_haiku_5_5_config.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""
|
||||
Validate Claude Haiku 5.5 model configuration entries.
|
||||
|
||||
Haiku 5.5 ships with adaptive thinking on by default, but unlike Sonnet 5.5 /
|
||||
Opus 5.5 thinking can still be turned off (``thinking: disabled`` at high
|
||||
effort or below) and it accepts a forced ``tool_choice`` (``any`` or a named
|
||||
tool). Its cost-map rows therefore carry ``thinking_always_on: false`` and
|
||||
``supports_forced_tool_use: true``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from typing import Final, cast
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
|
||||
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
|
||||
|
||||
REPO_ROOT: Final = os.path.join(os.path.dirname(__file__), "../..")
|
||||
|
||||
GET_WEATHER_TOOL: Final = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get the weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.get_model_info.cache_clear()
|
||||
yield
|
||||
litellm.get_model_info.cache_clear()
|
||||
|
||||
|
||||
def _load_root_cost_map() -> dict[str, dict[str, object]]:
|
||||
json_path: Final = os.path.join(REPO_ROOT, "model_prices_and_context_window.json")
|
||||
with open(json_path) as f:
|
||||
return cast(dict[str, dict[str, object]], json.load(f))
|
||||
|
||||
|
||||
HAIKU_5_5_VARIANTS: Final = (
|
||||
"claude-haiku-5-5",
|
||||
"anthropic.claude-haiku-5-5",
|
||||
"apac.anthropic.claude-haiku-5-5",
|
||||
"au.anthropic.claude-haiku-5-5",
|
||||
"eu.anthropic.claude-haiku-5-5",
|
||||
"global.anthropic.claude-haiku-5-5",
|
||||
"jp.anthropic.claude-haiku-5-5",
|
||||
"us.anthropic.claude-haiku-5-5",
|
||||
"us-gov.anthropic.claude-haiku-5-5",
|
||||
"bedrock/us-gov-east-1/anthropic.claude-haiku-5-5",
|
||||
"bedrock/us-gov-west-1/anthropic.claude-haiku-5-5",
|
||||
"bedrock_mantle/anthropic.claude-haiku-5-5",
|
||||
"bedrock_mantle/us-gov-west-1/anthropic.claude-haiku-5-5",
|
||||
"vertex_ai/claude-haiku-5-5",
|
||||
"vertex_ai/claude-haiku-5-5@default",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name", HAIKU_5_5_VARIANTS)
|
||||
def test_haiku_5_5_rows_allow_disabling_thinking_and_forced_tools(
|
||||
model_name: str,
|
||||
) -> None:
|
||||
root: Final = _load_root_cost_map()
|
||||
backup: Final = GetModelCostMap.load_local_model_cost_map()
|
||||
assert model_name in root
|
||||
row: Final = root[model_name]
|
||||
# https://platform.claude.com/docs/en/models/haiku-5-5/whats-new-haiku-5-5 (2026-10-07):
|
||||
# thinking can be disabled, forced tool_choice accepted
|
||||
assert row["thinking_always_on"] is False
|
||||
assert row["supports_forced_tool_use"] is True
|
||||
assert backup[model_name] == row
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "provider"),
|
||||
[
|
||||
("claude-haiku-5-5", "anthropic"),
|
||||
("anthropic/claude-haiku-5-5", "anthropic"),
|
||||
("vertex_ai/claude-haiku-5-5", "vertex_ai"),
|
||||
],
|
||||
)
|
||||
def test_haiku_5_5_runtime_profile(local_model_cost_map: None, model: str, provider: str) -> None:
|
||||
assert AnthropicModelInfo._is_adaptive_thinking_model(model, provider) is True
|
||||
assert AnthropicModelInfo._is_always_on_thinking_model(model, provider) is False
|
||||
assert AnthropicModelInfo.forced_tool_use_unsupported(model.removeprefix("anthropic/")) is False
|
||||
|
||||
|
||||
def test_haiku_5_5_anthropic_tool_choice_required_maps_to_any(
|
||||
local_model_cost_map: None,
|
||||
) -> None:
|
||||
optional_params: Final = litellm.AnthropicConfig().map_openai_params(
|
||||
non_default_params={
|
||||
"tools": [dict(GET_WEATHER_TOOL)],
|
||||
"tool_choice": "required",
|
||||
},
|
||||
optional_params={},
|
||||
model="claude-haiku-5-5",
|
||||
drop_params=False,
|
||||
)
|
||||
assert optional_params["tool_choice"] == {"type": "any"}
|
||||
|
||||
|
||||
def test_haiku_5_5_bedrock_tool_choice_required_maps_to_any(
|
||||
local_model_cost_map: None,
|
||||
) -> None:
|
||||
optional_params: Final = litellm.AmazonConverseConfig().map_openai_params(
|
||||
non_default_params={
|
||||
"tools": [dict(GET_WEATHER_TOOL)],
|
||||
"tool_choice": "required",
|
||||
},
|
||||
optional_params={},
|
||||
model="us.anthropic.claude-haiku-5-5",
|
||||
drop_params=False,
|
||||
)
|
||||
assert optional_params["tool_choice"] == {"any": {}}
|
||||
|
|
@ -236,10 +236,6 @@ def test_embedding_success_logging_and_spend_log_carry_no_forwarded_credentials(
|
|||
assert logging_obj.optional_params["extra_headers"] == {"x-goog-api-key": "goog-secret"}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_realtime_stream_combines_text_and_audio_token_details():
|
||||
"""Realtime response.done usage with input_token_details / output_token_details."""
|
||||
from litellm.cost_calculator import RealtimeAPITokenUsageProcessor
|
||||
|
|
@ -1037,9 +1033,9 @@ def test_per_query_priced_rerank_deployment_completion_cost_is_nonzero():
|
|||
router: Final = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "semantic-ranker-default-004",
|
||||
"model_name": "semantic-ranker-custom-test",
|
||||
"litellm_params": {
|
||||
"model": "vertex_ai/semantic-ranker-default-004",
|
||||
"model": "vertex_ai/semantic-ranker-custom-test",
|
||||
"vertex_project": "test-project",
|
||||
"vertex_location": "us-east5",
|
||||
},
|
||||
|
|
@ -1048,7 +1044,7 @@ def test_per_query_priced_rerank_deployment_completion_cost_is_nonzero():
|
|||
]
|
||||
)
|
||||
router_model_id: Final = router.model_list[0]["model_info"]["id"]
|
||||
assert litellm.model_cost["vertex_ai/semantic-ranker-default-004"].get("input_cost_per_query") is None
|
||||
assert litellm.model_cost.get("vertex_ai/semantic-ranker-custom-test", {}).get("input_cost_per_query") is None
|
||||
|
||||
response: Final = RerankResponse(
|
||||
id="vertex_ai_rerank_test",
|
||||
|
|
@ -1058,7 +1054,7 @@ def test_per_query_priced_rerank_deployment_completion_cost_is_nonzero():
|
|||
|
||||
cost: Final = completion_cost(
|
||||
completion_response=response,
|
||||
model="vertex_ai/semantic-ranker-default-004",
|
||||
model="vertex_ai/semantic-ranker-custom-test",
|
||||
custom_llm_provider="vertex_ai",
|
||||
call_type="arerank",
|
||||
custom_pricing=True,
|
||||
|
|
@ -1354,8 +1350,6 @@ def test_bedrock_cost_calculator_comparison_with_without_cache():
|
|||
print(f"Cost with cache: {cost_with_cache}")
|
||||
|
||||
|
||||
|
||||
|
||||
def test_gemini_25_explicit_caching_cost_direct_usage():
|
||||
"""
|
||||
Test that Gemini 2.5 models correctly calculate costs with explicit caching.
|
||||
|
|
@ -1924,8 +1918,6 @@ def test_cost_margin_with_discount(monkeypatch):
|
|||
print(f" - Expected: ${expected_cost:.6f}")
|
||||
|
||||
|
||||
|
||||
|
||||
def test_completion_cost_extracts_service_tier_from_response(_local_model_cost_map):
|
||||
"""Test that completion_cost extracts service_tier from completion_response object."""
|
||||
from litellm import completion_cost
|
||||
|
|
@ -2675,8 +2667,6 @@ def test_gemini_without_cache_tokens_details():
|
|||
print("✅ Gemini without cacheTokensDetails works correctly")
|
||||
|
||||
|
||||
|
||||
|
||||
def test_additional_costs_only_for_azure_ai(_local_model_cost_map):
|
||||
"""
|
||||
Test that _get_additional_costs is only called for azure_ai provider.
|
||||
|
|
@ -5663,3 +5653,531 @@ def test_completion_cost_bills_base_when_gemini_serves_on_demand(
|
|||
)
|
||||
|
||||
assert cost == pytest.approx(100 * 0.001 + 50 * 0.002)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile_prefix", ("us", "global"))
|
||||
@pytest.mark.parametrize(
|
||||
"model_name",
|
||||
("gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-6-sol", "gpt-6-luna", "gpt-6-astra", "gpt-6.1-sol"),
|
||||
)
|
||||
@pytest.mark.parametrize("arn", (False, True))
|
||||
def test_bedrock_converse_cost_uses_invoked_profile(
|
||||
_local_model_cost_map: None, model_name: str, profile_prefix: str, arn: bool
|
||||
) -> None:
|
||||
model: Final = f"openai.{model_name}"
|
||||
profile: Final = f"{profile_prefix}.{model}"
|
||||
invoked: Final = f"arn:aws:bedrock:us-east-1:123456789012:inference-profile/{profile}" if arn else profile
|
||||
response: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
prices: Final = litellm.get_model_info(profile, custom_llm_provider="bedrock")
|
||||
|
||||
cost: Final = completion_cost(
|
||||
completion_response=response,
|
||||
model=f"bedrock/converse/{model}",
|
||||
custom_llm_provider="bedrock",
|
||||
optional_params={"model_id": invoked},
|
||||
)
|
||||
|
||||
assert cost == pytest.approx(10 * prices["input_cost_per_token"] + 5 * prices["output_cost_per_token"])
|
||||
assert response.model == model
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom_pricing", (False, True))
|
||||
def test_bedrock_converse_profile_preserves_explicit_base_and_custom_pricing(
|
||||
_local_model_cost_map: None, custom_pricing: bool
|
||||
) -> None:
|
||||
model: Final = "openai.gpt-6-sol"
|
||||
response: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
prices: Final = litellm.get_model_info(model, custom_llm_provider="bedrock")
|
||||
|
||||
cost: Final = completion_cost(
|
||||
completion_response=response,
|
||||
model=model,
|
||||
custom_llm_provider="bedrock",
|
||||
optional_params={"model_id": f"us.{model}"},
|
||||
custom_pricing=custom_pricing,
|
||||
base_model=None if custom_pricing else model,
|
||||
)
|
||||
|
||||
assert cost == pytest.approx(10 * prices["input_cost_per_token"] + 5 * prices["output_cost_per_token"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", (False, True))
|
||||
def test_bedrock_converse_cost_retains_profile_after_dispatch_consumes_optional_params(
|
||||
_local_model_cost_map: None, stream: bool
|
||||
) -> None:
|
||||
from litellm.cost_calculator import pricing_entry_for_cost_calc
|
||||
|
||||
model: Final = "openai.gpt-5.6-sol"
|
||||
profile: Final = f"us.{model}"
|
||||
logging_obj: Final = Logging(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=stream,
|
||||
call_type="completion",
|
||||
start_time=datetime.datetime(2026, 1, 1),
|
||||
litellm_call_id="bedrock-profile-cost",
|
||||
function_id="bedrock-profile-cost",
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
litellm_params={}, optional_params={"model_id": profile}, custom_llm_provider="bedrock"
|
||||
)
|
||||
logging_obj.optional_params.pop("model_id")
|
||||
response: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
prices: Final = litellm.get_model_info(profile, custom_llm_provider="bedrock")
|
||||
|
||||
assert logging_obj._response_cost_calculator(response) == pytest.approx(
|
||||
10 * prices["input_cost_per_token"] + 5 * prices["output_cost_per_token"]
|
||||
)
|
||||
entry: Final = pricing_entry_for_cost_calc(model, response, "bedrock", False, None, None, None, logging_obj)
|
||||
assert entry is not None
|
||||
assert entry[0] == prices["key"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invoked_model_id",
|
||||
(None, "", 123, "unmapped-profile", "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opaque"),
|
||||
)
|
||||
def test_bedrock_converse_cost_keeps_configured_rates_when_target_has_no_prices(
|
||||
_local_model_cost_map: None, invoked_model_id: object
|
||||
) -> None:
|
||||
model: Final = "openai.gpt-6-sol"
|
||||
response: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
expected: Final = completion_cost(completion_response=response, model=model, custom_llm_provider="bedrock")
|
||||
|
||||
assert (
|
||||
completion_cost(
|
||||
completion_response=response,
|
||||
model=model,
|
||||
custom_llm_provider="bedrock",
|
||||
optional_params={"model_id": invoked_model_id},
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
def test_non_bedrock_cost_ignores_bedrock_model_id(_local_model_cost_map: None) -> None:
|
||||
response: Final = ModelResponse(
|
||||
model="gpt-6-sol", usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15)
|
||||
)
|
||||
expected: Final = completion_cost(completion_response=response, custom_llm_provider="openai")
|
||||
|
||||
assert (
|
||||
completion_cost(
|
||||
completion_response=response,
|
||||
custom_llm_provider="openai",
|
||||
optional_params={"model_id": "us.openai.gpt-6-sol"},
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rate", (0.0, 0.001))
|
||||
@pytest.mark.parametrize("persist_across_reloads", (True, False))
|
||||
@pytest.mark.parametrize("routed_response", (True, False))
|
||||
@pytest.mark.parametrize("metadata_prefix", ("bedrock/", "bedrock/converse/"))
|
||||
def test_bedrock_converse_profile_preserves_registered_price_override(
|
||||
_local_model_cost_map: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
rate: float,
|
||||
persist_across_reloads: bool,
|
||||
routed_response: bool,
|
||||
metadata_prefix: str,
|
||||
) -> None:
|
||||
import litellm.utils as model_utils
|
||||
from litellm.cost_calculator import pricing_entry_for_cost_calc
|
||||
|
||||
monkeypatch.setattr(model_utils, "_runtime_registered_model_cost", {})
|
||||
catalog: Final = dict(litellm.model_cost)
|
||||
model: Final = "openai.gpt-5.6-sol"
|
||||
litellm.register_model(
|
||||
{f"{metadata_prefix}{model}": {"supports_function_calling": True}},
|
||||
persist_across_reloads=persist_across_reloads,
|
||||
)
|
||||
litellm.register_model(
|
||||
{
|
||||
model: {
|
||||
"input_cost_per_token": rate,
|
||||
"output_cost_per_token": rate,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"mode": "chat",
|
||||
}
|
||||
},
|
||||
persist_across_reloads=persist_across_reloads,
|
||||
)
|
||||
litellm.register_model(
|
||||
{model: {"supports_function_calling": True, "input_cost_per_token": None}},
|
||||
persist_across_reloads=persist_across_reloads,
|
||||
)
|
||||
response: Final = ModelResponse(
|
||||
model=f"bedrock/converse/{model}" if routed_response else model,
|
||||
usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
||||
)
|
||||
|
||||
logging_obj: Final = Logging(
|
||||
model=model,
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time=datetime.datetime(2026, 1, 1),
|
||||
litellm_call_id="registered-pricing",
|
||||
function_id="registered-pricing",
|
||||
)
|
||||
assert completion_cost(
|
||||
completion_response=response,
|
||||
model=f"bedrock/converse/{model}",
|
||||
custom_llm_provider="bedrock",
|
||||
optional_params={"model_id": f"us.{model}"},
|
||||
litellm_logging_obj=logging_obj,
|
||||
) == pytest.approx(15 * rate)
|
||||
|
||||
assert logging_obj.billed_token_rates is not None
|
||||
assert logging_obj.billed_token_rates.input_cost_per_token == rate
|
||||
assert logging_obj.billed_token_rates.output_cost_per_token == rate
|
||||
entry: Final = pricing_entry_for_cost_calc(model, response, "bedrock", False, None, None, None, None)
|
||||
assert entry is not None
|
||||
assert entry[0] == model
|
||||
assert entry[1]["input_cost_per_token"] == rate
|
||||
assert entry[1]["output_cost_per_token"] == rate
|
||||
|
||||
updated_rate: Final = rate + 0.002
|
||||
litellm.register_model(
|
||||
{model: {"input_cost_per_token": str(updated_rate)}}, persist_across_reloads=persist_across_reloads
|
||||
)
|
||||
assert completion_cost(
|
||||
completion_response=response,
|
||||
model=f"bedrock/converse/{model}",
|
||||
custom_llm_provider="bedrock",
|
||||
optional_params={"model_id": f"us.{model}"},
|
||||
) == pytest.approx(10 * updated_rate + 5 * rate)
|
||||
|
||||
monkeypatch.setattr(litellm, "model_cost", catalog)
|
||||
model_utils._invalidate_model_cost_lowercase_map()
|
||||
model_utils.reapply_runtime_model_cost_registrations()
|
||||
profile_prices: Final = litellm.get_model_info(f"us.{model}", custom_llm_provider="bedrock")
|
||||
expected_after_reload: Final = (
|
||||
10 * updated_rate + 5 * rate
|
||||
if persist_across_reloads
|
||||
else 10 * profile_prices["input_cost_per_token"] + 5 * profile_prices["output_cost_per_token"]
|
||||
)
|
||||
assert completion_cost(
|
||||
completion_response=response,
|
||||
model=f"bedrock/converse/{model}",
|
||||
custom_llm_provider="bedrock",
|
||||
optional_params={"model_id": f"us.{model}"},
|
||||
) == pytest.approx(expected_after_reload)
|
||||
|
||||
|
||||
def test_bedrock_converse_profile_ignores_registered_capabilities_without_prices(
|
||||
_local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import litellm.utils as model_utils
|
||||
|
||||
monkeypatch.setattr(model_utils, "_runtime_registered_model_cost", {})
|
||||
model: Final = "openai.gpt-6-sol"
|
||||
litellm.register_model({model: {"supports_function_calling": True}})
|
||||
response: Final = ModelResponse(model=model, usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
||||
prices: Final = litellm.get_model_info(f"us.{model}", custom_llm_provider="bedrock")
|
||||
|
||||
assert completion_cost(
|
||||
completion_response=response,
|
||||
model=model,
|
||||
custom_llm_provider="bedrock",
|
||||
optional_params={"model_id": f"us.{model}"},
|
||||
) == pytest.approx(10 * prices["input_cost_per_token"] + 5 * prices["output_cost_per_token"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rate", (0.0, 0.001))
|
||||
@pytest.mark.parametrize("cache_field", ("cache_read_input_token_cost", "cache_creation_input_token_cost"))
|
||||
@pytest.mark.parametrize("has_base_rates", (False, True))
|
||||
@pytest.mark.parametrize("persist_across_reloads", (False, True))
|
||||
@pytest.mark.parametrize("profile_prefix", ("us", "global"))
|
||||
@pytest.mark.parametrize("provider", (None, "bedrock"))
|
||||
def test_bedrock_converse_profile_preserves_cache_only_registered_prices(
|
||||
_local_model_cost_map: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
rate: float,
|
||||
cache_field: str,
|
||||
has_base_rates: bool,
|
||||
persist_across_reloads: bool,
|
||||
profile_prefix: str,
|
||||
provider: str | None,
|
||||
) -> None:
|
||||
import litellm.utils as model_utils
|
||||
from litellm.cost_calculator import pricing_entry_for_cost_calc
|
||||
|
||||
monkeypatch.setattr(model_utils, "_runtime_registered_model_cost", {})
|
||||
model: Final = "openai.cache-overlay-test"
|
||||
profile: Final = f"{profile_prefix}.{model}"
|
||||
catalog: Final = {
|
||||
**litellm.model_cost,
|
||||
model: {
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "chat",
|
||||
**({"input_cost_per_token": 0.01, "output_cost_per_token": 0.02} if has_base_rates else {}),
|
||||
},
|
||||
profile: {
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 0.02,
|
||||
"output_cost_per_token": 0.04,
|
||||
"cache_read_input_token_cost": 0.005,
|
||||
"cache_creation_input_token_cost": 0.025,
|
||||
},
|
||||
}
|
||||
profile_before: Final = dict(catalog[profile])
|
||||
monkeypatch.setattr(litellm, "model_cost", dict(catalog))
|
||||
model_utils._invalidate_model_cost_lowercase_map()
|
||||
litellm.register_model({model: {cache_field: rate}}, persist_across_reloads=persist_across_reloads)
|
||||
response: Final = ModelResponse(
|
||||
model=model,
|
||||
usage=Usage(
|
||||
prompt_tokens=15,
|
||||
completion_tokens=3,
|
||||
total_tokens=18,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
cached_tokens=5 if cache_field == "cache_read_input_token_cost" else 0
|
||||
),
|
||||
cache_creation_input_tokens=5 if cache_field == "cache_creation_input_token_cost" else 0,
|
||||
),
|
||||
)
|
||||
logging_obj: Final = Logging(
|
||||
model=model,
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time=datetime.datetime(2026, 1, 1),
|
||||
litellm_call_id="cache-overlay",
|
||||
function_id="cache-overlay",
|
||||
)
|
||||
logging_obj.model_call_details["model_id"] = profile
|
||||
expected: Final = 10 * 0.02 + 3 * 0.04 + 5 * rate
|
||||
assert completion_cost(
|
||||
completion_response=response,
|
||||
model=f"bedrock/converse/{model}",
|
||||
custom_llm_provider=provider,
|
||||
optional_params={"model_id": profile},
|
||||
litellm_logging_obj=logging_obj,
|
||||
) == pytest.approx(expected)
|
||||
breakdown_key: Final = "cache_read_cost" if cache_field == "cache_read_input_token_cost" else "cache_creation_cost"
|
||||
assert logging_obj.cost_breakdown is not None
|
||||
assert logging_obj.cost_breakdown.get(breakdown_key, 0.0) == pytest.approx(5 * rate)
|
||||
assert logging_obj.billed_token_rates is not None
|
||||
assert (
|
||||
logging_obj.billed_token_rates.cache_read_input_token_cost
|
||||
if cache_field == "cache_read_input_token_cost"
|
||||
else logging_obj.billed_token_rates.cache_creation_input_token_cost
|
||||
) == rate
|
||||
entry: Final = pricing_entry_for_cost_calc(
|
||||
f"bedrock/converse/{model}", response, provider, False, None, None, None, logging_obj
|
||||
)
|
||||
assert entry is not None
|
||||
assert entry[1]["input_cost_per_token"] == 0.02
|
||||
assert entry[1]["output_cost_per_token"] == 0.04
|
||||
assert entry[1][cache_field] == rate
|
||||
assert litellm.model_cost[profile] == profile_before
|
||||
|
||||
monkeypatch.setattr(litellm, "model_cost", dict(catalog))
|
||||
model_utils._invalidate_model_cost_lowercase_map()
|
||||
model_utils.reapply_runtime_model_cost_registrations()
|
||||
expected_after_reload: Final = (
|
||||
10 * 0.02 + 3 * 0.04 + 5 * (rate if persist_across_reloads else catalog[profile][cache_field])
|
||||
)
|
||||
assert completion_cost(
|
||||
completion_response=response,
|
||||
model=f"bedrock/converse/{model}",
|
||||
custom_llm_provider=provider,
|
||||
optional_params={"model_id": profile},
|
||||
litellm_logging_obj=logging_obj,
|
||||
) == pytest.approx(expected_after_reload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile", ("us.openai.missing-profile-test", "us.openai.unpriced-profile-test"))
|
||||
def test_bedrock_cache_registration_with_unpriced_profile_keeps_registered_rates(
|
||||
_local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch, profile: str
|
||||
) -> None:
|
||||
import litellm.utils as model_utils
|
||||
|
||||
model: Final = "openai.registered-cache-test"
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"model_cost",
|
||||
{
|
||||
**litellm.model_cost,
|
||||
model: {
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 0.01,
|
||||
"output_cost_per_token": 0.02,
|
||||
},
|
||||
"us.openai.unpriced-profile-test": {"litellm_provider": "bedrock", "mode": "chat"},
|
||||
},
|
||||
)
|
||||
model_utils._invalidate_model_cost_lowercase_map()
|
||||
litellm.register_model({model: {"cache_read_input_token_cost": 0.001}}, persist_across_reloads=False)
|
||||
response: Final = ModelResponse(
|
||||
model=model,
|
||||
usage=Usage(
|
||||
prompt_tokens=15,
|
||||
completion_tokens=3,
|
||||
total_tokens=18,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=5),
|
||||
),
|
||||
)
|
||||
assert completion_cost(
|
||||
completion_response=response,
|
||||
model=f"bedrock/converse/{model}",
|
||||
custom_llm_provider="bedrock",
|
||||
optional_params={"model_id": profile},
|
||||
) == pytest.approx(10 * 0.01 + 3 * 0.02 + 5 * 0.001)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("direction", ("input", "output"))
|
||||
@pytest.mark.parametrize("rate", (0.0, 0.004))
|
||||
@pytest.mark.parametrize("prompt_tokens", (100, 300_001))
|
||||
def test_bedrock_partial_registration_overrides_only_its_profile_direction(
|
||||
_local_model_cost_map: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
direction: str,
|
||||
rate: float,
|
||||
prompt_tokens: int,
|
||||
) -> None:
|
||||
from litellm.cost_calculator import pricing_entry_for_cost_calc
|
||||
|
||||
model: Final = "openai.partial-pricing-test"
|
||||
profile: Final = f"us.{model}"
|
||||
profile_entry: Final = {
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 0.01,
|
||||
"output_cost_per_token": 0.02,
|
||||
"input_cost_per_token_above_300k_tokens": 0.03,
|
||||
"output_cost_per_token_above_300k_tokens": 0.06,
|
||||
"cache_read_input_token_cost": 0.001,
|
||||
}
|
||||
monkeypatch.setattr(litellm, "model_cost", {**litellm.model_cost, profile: dict(profile_entry)})
|
||||
litellm.register_model({model: {f"{direction}_cost_per_token": rate}}, persist_across_reloads=False)
|
||||
response: Final = ModelResponse(
|
||||
model=model,
|
||||
usage=Usage(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=3,
|
||||
total_tokens=prompt_tokens + 3,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=5),
|
||||
),
|
||||
)
|
||||
logging_obj: Final = Logging(
|
||||
model=model,
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time=datetime.datetime(2026, 1, 1),
|
||||
litellm_call_id="partial-pricing",
|
||||
function_id="partial-pricing",
|
||||
)
|
||||
logging_obj.model_call_details["model_id"] = profile
|
||||
input_rate: Final = rate if direction == "input" else (0.03 if prompt_tokens > 300_000 else 0.01)
|
||||
output_rate: Final = rate if direction == "output" else (0.06 if prompt_tokens > 300_000 else 0.02)
|
||||
assert completion_cost(
|
||||
completion_response=response,
|
||||
model=model,
|
||||
custom_llm_provider="bedrock",
|
||||
litellm_logging_obj=logging_obj,
|
||||
) == pytest.approx((prompt_tokens - 5) * input_rate + 3 * output_rate + 5 * 0.001)
|
||||
assert logging_obj.billed_token_rates is not None
|
||||
assert logging_obj.billed_token_rates.input_cost_per_token == input_rate
|
||||
assert logging_obj.billed_token_rates.output_cost_per_token == output_rate
|
||||
assert logging_obj.cost_breakdown is not None
|
||||
assert logging_obj.cost_breakdown["cache_read_cost"] == pytest.approx(5 * 0.001)
|
||||
entry: Final = pricing_entry_for_cost_calc(model, response, "bedrock", False, None, None, None, logging_obj)
|
||||
assert entry is not None
|
||||
assert entry[1][f"{direction}_cost_per_token"] == rate
|
||||
assert litellm.model_cost[profile] == profile_entry
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt_tokens", (299_999, 300_000, 300_001))
|
||||
@pytest.mark.parametrize("cache_field", ("cache_read_input_token_cost", "cache_creation_input_token_cost"))
|
||||
@pytest.mark.parametrize("tier_override", (None, 0.0, 0.003))
|
||||
def test_bedrock_registered_cache_prices_preserve_dynamic_threshold_rates(
|
||||
_local_model_cost_map: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
prompt_tokens: int,
|
||||
cache_field: str,
|
||||
tier_override: float | None,
|
||||
) -> None:
|
||||
import litellm.utils as model_utils
|
||||
from litellm.cost_calculator import pricing_entry_for_cost_calc
|
||||
|
||||
model: Final = "openai.threshold-overlay-test"
|
||||
profile: Final = f"us.{model}"
|
||||
tier_field: Final = f"{cache_field}_above_300k_tokens"
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"model_cost",
|
||||
{
|
||||
**litellm.model_cost,
|
||||
profile: {
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 0.01,
|
||||
"output_cost_per_token": 0.02,
|
||||
"input_cost_per_token_above_300k_tokens": 0.03,
|
||||
"output_cost_per_token_above_300k_tokens": 0.06,
|
||||
tier_field: 0.007,
|
||||
},
|
||||
},
|
||||
)
|
||||
model_utils._invalidate_model_cost_lowercase_map()
|
||||
litellm.register_model(
|
||||
{
|
||||
model: {
|
||||
cache_field: 0.001,
|
||||
**({tier_field: tier_override} if tier_override is not None else {}),
|
||||
}
|
||||
},
|
||||
persist_across_reloads=False,
|
||||
)
|
||||
usage: Final = Usage(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=3,
|
||||
total_tokens=prompt_tokens + 3,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
cached_tokens=5 if cache_field == "cache_read_input_token_cost" else 0
|
||||
),
|
||||
cache_creation_input_tokens=5 if cache_field == "cache_creation_input_token_cost" else 0,
|
||||
)
|
||||
response: Final = ModelResponse(model=model, usage=usage)
|
||||
logging_obj: Final = Logging(
|
||||
model=model,
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time=datetime.datetime(2026, 1, 1),
|
||||
litellm_call_id="threshold-overlay",
|
||||
function_id="threshold-overlay",
|
||||
)
|
||||
logging_obj.model_call_details["model_id"] = profile
|
||||
expected_input_rate: Final = 0.03 if prompt_tokens > 300_000 else 0.01
|
||||
expected_output_rate: Final = 0.06 if prompt_tokens > 300_000 else 0.02
|
||||
expected_cache_rate: Final = (
|
||||
(tier_override if tier_override is not None else 0.007) if prompt_tokens > 300_000 else 0.001
|
||||
)
|
||||
total: Final = completion_cost(
|
||||
completion_response=response,
|
||||
model=f"bedrock/converse/{model}",
|
||||
custom_llm_provider="bedrock",
|
||||
optional_params={"model_id": profile},
|
||||
litellm_logging_obj=logging_obj,
|
||||
)
|
||||
assert total == pytest.approx(
|
||||
(prompt_tokens - 5) * expected_input_rate + 3 * expected_output_rate + 5 * expected_cache_rate
|
||||
)
|
||||
breakdown_key: Final = "cache_read_cost" if cache_field == "cache_read_input_token_cost" else "cache_creation_cost"
|
||||
assert logging_obj.cost_breakdown is not None
|
||||
assert logging_obj.cost_breakdown.get(breakdown_key, 0.0) == pytest.approx(5 * expected_cache_rate)
|
||||
assert logging_obj.billed_token_rates is not None
|
||||
assert logging_obj.billed_token_rates.input_cost_per_token == expected_input_rate
|
||||
assert logging_obj.billed_token_rates.output_cost_per_token == expected_output_rate
|
||||
entry: Final = pricing_entry_for_cost_calc(model, response, "bedrock", False, None, None, None, logging_obj)
|
||||
assert entry is not None
|
||||
assert entry[1]["input_cost_per_token_above_300k_tokens"] == 0.03
|
||||
assert entry[1][tier_field] == (tier_override if tier_override is not None else 0.007)
|
||||
|
|
|
|||
|
|
@ -1043,6 +1043,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
|
||||
|
|
@ -1419,11 +1463,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(
|
||||
|
|
@ -1441,7 +1489,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"),
|
||||
|
|
@ -4560,3 +4607,98 @@ def test_drop_params_false_still_rejects_an_invalid_stream_chunk_size() -> None:
|
|||
drop_params=False,
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", (False, True))
|
||||
@pytest.mark.parametrize("asynchronous", (False, True))
|
||||
@pytest.mark.parametrize("profile,custom_price", ((None, False), ("us.openai.gpt-6-sol", False), (None, True)))
|
||||
@pytest.mark.asyncio
|
||||
async def test_converse_fallback_prices_the_current_attempt(
|
||||
monkeypatch: pytest.MonkeyPatch, stream: bool, asynchronous: bool, profile: str | None, custom_price: bool
|
||||
) -> None:
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.rust_bridge import configuration
|
||||
CONVERSE_ANSWER: Final = json.dumps(
|
||||
{
|
||||
"output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2},
|
||||
"metrics": {"latencyMs": 1},
|
||||
}
|
||||
).encode()
|
||||
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setenv("LITELLM_RUST", "0")
|
||||
configuration.rust(False)
|
||||
monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost))
|
||||
failed_profile: Final = "us.amazon.nova-micro-v1:0"
|
||||
model: Final = "openai.gpt-6-sol"
|
||||
logging_obj: Final = LiteLLMLogging(
|
||||
model="amazon.nova-micro-v1:0",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=stream,
|
||||
call_type="acompletion" if asynchronous else "completion",
|
||||
start_time=datetime(2026, 1, 1),
|
||||
litellm_call_id="bedrock-fallback-pricing",
|
||||
function_id="bedrock-fallback-pricing",
|
||||
)
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
if failed_profile in request.url.path:
|
||||
return httpx.Response(400, json={"message": "Input is too long for requested model."})
|
||||
assert request.url.path == f"/model/{profile or model}/converse"
|
||||
return httpx.Response(200, content=CONVERSE_ANSWER)
|
||||
|
||||
client: Final = (
|
||||
AsyncHTTPHandler(transport=httpx.MockTransport(respond))
|
||||
if asynchronous
|
||||
else HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond)))
|
||||
)
|
||||
kwargs: Final = {
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"api_key": "test-bearer",
|
||||
"aws_region_name": "us-east-1",
|
||||
"client": client,
|
||||
"litellm_logging_obj": logging_obj,
|
||||
"num_retries": 0,
|
||||
"stream": stream,
|
||||
"fake_stream": stream,
|
||||
}
|
||||
if asynchronous:
|
||||
with pytest.raises(litellm.BadRequestError):
|
||||
await litellm.acompletion(model="bedrock/converse/amazon.nova-micro-v1:0", model_id=failed_profile, **kwargs)
|
||||
else:
|
||||
with pytest.raises(litellm.BadRequestError):
|
||||
litellm.completion(model="bedrock/converse/amazon.nova-micro-v1:0", model_id=failed_profile, **kwargs)
|
||||
|
||||
overrides: Final = {
|
||||
**({"model_id": profile} if profile is not None else {}),
|
||||
**({"input_cost_per_token": 0.001, "output_cost_per_token": 0.002} if custom_price else {}),
|
||||
}
|
||||
response: Final = (
|
||||
await litellm.acompletion(model=f"bedrock/converse/{model}", **kwargs, **overrides)
|
||||
if asynchronous
|
||||
else litellm.completion(model=f"bedrock/converse/{model}", **kwargs, **overrides)
|
||||
)
|
||||
result: Final = (
|
||||
litellm.stream_chunk_builder(
|
||||
[chunk async for chunk in response] if asynchronous else list(response), logging_obj=response.logging_obj
|
||||
)
|
||||
if stream
|
||||
else response
|
||||
)
|
||||
assert result is not None
|
||||
prices: Final = litellm.get_model_info(profile or model, custom_llm_provider="bedrock")
|
||||
expected: Final = (
|
||||
0.001 + 0.002
|
||||
if custom_price
|
||||
else prices["input_cost_per_token"] + prices["output_cost_per_token"]
|
||||
)
|
||||
active_logging: Final = response.logging_obj if stream else logging_obj
|
||||
assert active_logging._response_cost_calculator(result) == pytest.approx(expected)
|
||||
active_logging.update_environment_variables(litellm_params={}, optional_params={})
|
||||
if asynchronous:
|
||||
await client.close()
|
||||
else:
|
||||
client.close()
|
||||
configuration.reset_rust_configuration()
|
||||
|
|
|
|||
|
|
@ -511,7 +511,19 @@ def test_deepseek_rows_bill_half_rate_outside_weekday_peak_hours(path: Path):
|
|||
assert all(name in rows for name in DEEPSEEK_PRICED_ROWS)
|
||||
|
||||
|
||||
PROVIDER_LABELS_WITHOUT_A_MODEL_SET: Final = frozenset({"sagemaker", "bedrock_converse"})
|
||||
PROVIDER_LABELS_WITHOUT_A_MODEL_SET: Final = frozenset(
|
||||
{
|
||||
"sagemaker",
|
||||
"bedrock_converse",
|
||||
"coralbricks",
|
||||
"inferenceapis",
|
||||
"microsoft_365_copilot",
|
||||
"openinfer",
|
||||
"prism",
|
||||
"sail",
|
||||
"scaledown",
|
||||
}
|
||||
)
|
||||
MODES_SERVED_OUTSIDE_THE_LLM_PROVIDER_REGISTRY: Final = frozenset({"search", "evaluation"})
|
||||
VERTEX_FAMILIES_A_VERTEX_WILDCARD_GRANT_DOES_NOT_LIST: Final = frozenset(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -762,6 +762,178 @@ def test_should_not_downgrade_chatgpt_shared_key_mode_with_alias_override():
|
|||
_restore_model_cost_entries(model_keys)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service_tier", ("default", "priority", "flex"))
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
("input_cost_per_token", "output_cost_per_token", "cache_read_input_token_cost", "input_cost_per_token_priority"),
|
||||
)
|
||||
@pytest.mark.parametrize("rate", (0.0, 0.004))
|
||||
@pytest.mark.parametrize("explicit_base", (False, True))
|
||||
@pytest.mark.parametrize("provider", (None, "bedrock"))
|
||||
@pytest.mark.parametrize("registration_path", ("direct", "startup", "reload"))
|
||||
def test_bedrock_deployment_inherits_invoked_profile_around_custom_prices(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
field: str,
|
||||
rate: float,
|
||||
explicit_base: bool,
|
||||
provider: str | None,
|
||||
registration_path: str,
|
||||
service_tier: str,
|
||||
) -> None:
|
||||
from litellm.cost_calculator import completion_cost
|
||||
from litellm.types.utils import ModelResponse, Usage, PromptTokensDetailsWrapper
|
||||
|
||||
model: Final = "openai.gpt-5.6-sol"
|
||||
profile: Final = f"us.{model}"
|
||||
base_model: Final = f"global.{model}"
|
||||
profile_prices: Final = {
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 0.01,
|
||||
"output_cost_per_token": 0.02,
|
||||
"cache_read_input_token_cost": 0.001,
|
||||
"input_cost_per_token_priority": 0.05,
|
||||
"output_cost_per_token_priority": 0.06,
|
||||
"input_cost_per_token_flex": 0.007,
|
||||
"output_cost_per_token_flex": 0.008,
|
||||
}
|
||||
base_prices: Final = {**profile_prices, "input_cost_per_token": 0.03, "output_cost_per_token": 0.04}
|
||||
monkeypatch.setattr(
|
||||
litellm, "model_cost", {**litellm.model_cost, profile: dict(profile_prices), base_model: dict(base_prices)}
|
||||
)
|
||||
deployment: Final = Deployment(
|
||||
model_name="profile-priced",
|
||||
litellm_params=LiteLLM_Params(
|
||||
model=f"converse/{model}" if provider else f"bedrock/converse/{model}",
|
||||
model_id=profile,
|
||||
custom_llm_provider=provider,
|
||||
),
|
||||
model_info=ModelInfo(
|
||||
**{
|
||||
"id": "profile-priced-deployment",
|
||||
field: rate,
|
||||
**({"base_model": f"bedrock/{base_model}"} if explicit_base else {}),
|
||||
}
|
||||
),
|
||||
)
|
||||
if registration_path == "direct":
|
||||
Router._register_deployment_pricing(deployment)
|
||||
else:
|
||||
router: Final = Router(model_list=[deployment.to_json(exclude_none=True)])
|
||||
if registration_path == "reload":
|
||||
router._replay_model_cost_registrations()
|
||||
response: Final = ModelResponse(
|
||||
model=model,
|
||||
usage=Usage(
|
||||
prompt_tokens=100,
|
||||
completion_tokens=3,
|
||||
total_tokens=103,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=5),
|
||||
),
|
||||
)
|
||||
catalog: Final = base_prices if explicit_base else profile_prices
|
||||
input_field: Final = "input_cost_per_token" + (f"_{service_tier}" if service_tier != "default" else "")
|
||||
output_field: Final = "output_cost_per_token" + (f"_{service_tier}" if service_tier != "default" else "")
|
||||
input_rate: Final = rate if field == input_field else catalog[input_field]
|
||||
output_rate: Final = rate if field == output_field else catalog[output_field]
|
||||
cache_rate: Final = rate if field == "cache_read_input_token_cost" else 0.001
|
||||
assert completion_cost(
|
||||
model=model,
|
||||
completion_response=response,
|
||||
custom_llm_provider="bedrock",
|
||||
custom_pricing=True,
|
||||
router_model_id="profile-priced-deployment",
|
||||
service_tier=service_tier,
|
||||
) == pytest.approx(95 * input_rate + 3 * output_rate + 5 * cache_rate)
|
||||
assert litellm.model_cost[profile] == profile_prices
|
||||
|
||||
|
||||
@pytest.mark.parametrize("catalog_entry", (None, {"litellm_provider": 1, "mode": "chat"}))
|
||||
@pytest.mark.parametrize("known_model", (False, True))
|
||||
@pytest.mark.parametrize("explicit_base", (False, True))
|
||||
@pytest.mark.parametrize("service_tier", ("default", "priority", "flex"))
|
||||
@pytest.mark.parametrize("override", (None, "cache_read_input_token_cost", "input_cost_per_token_priority"))
|
||||
@pytest.mark.parametrize("reload", (False, True))
|
||||
def test_bedrock_deployment_keeps_custom_prices_when_profile_lookup_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
catalog_entry: dict[str, object] | None,
|
||||
known_model: bool,
|
||||
explicit_base: bool,
|
||||
service_tier: str,
|
||||
override: str | None,
|
||||
reload: bool,
|
||||
) -> None:
|
||||
from litellm.cost_calculator import completion_cost
|
||||
from litellm.types.utils import ModelResponse, Usage, PromptTokensDetailsWrapper
|
||||
|
||||
model: Final = "anthropic.unmapped-profile-pricing-test"
|
||||
profile: Final = (
|
||||
"us.anthropic.invalid-profile-pricing-test"
|
||||
if catalog_entry is not None
|
||||
else "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opaque-test"
|
||||
)
|
||||
configured_prices: Final = {
|
||||
"litellm_provider": "bedrock",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 0.03,
|
||||
"output_cost_per_token": 0.04,
|
||||
"cache_read_input_token_cost": 0.001,
|
||||
"input_cost_per_token_priority": 0.05,
|
||||
"output_cost_per_token_priority": 0.06,
|
||||
"input_cost_per_token_flex": 0.007,
|
||||
"output_cost_per_token_flex": 0.008,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"model_cost",
|
||||
{
|
||||
**litellm.model_cost,
|
||||
**({model: dict(configured_prices)} if known_model else {}),
|
||||
**({profile: catalog_entry} if catalog_entry is not None else {}),
|
||||
},
|
||||
)
|
||||
deployment: Final = Deployment(
|
||||
model_name="custom-priced",
|
||||
litellm_params=LiteLLM_Params(model=f"bedrock/converse/{model}", model_id=profile),
|
||||
model_info=ModelInfo(
|
||||
id="unmapped-profile-deployment",
|
||||
input_cost_per_token=0.01,
|
||||
output_cost_per_token=0.02,
|
||||
**({override: 0.0} if override else {}),
|
||||
**({"base_model": f"bedrock/{profile}"} if explicit_base else {}),
|
||||
),
|
||||
)
|
||||
router: Final = Router(model_list=[deployment.to_json(exclude_none=True)])
|
||||
if reload:
|
||||
router._replay_model_cost_registrations()
|
||||
inherit_configured: Final = known_model and not explicit_base
|
||||
tier_rates: Final = inherit_configured and service_tier != "default"
|
||||
input_rate: Final = configured_prices[f"input_cost_per_token_{service_tier}"] if tier_rates else 0.01
|
||||
output_rate: Final = configured_prices[f"output_cost_per_token_{service_tier}"] if tier_rates else 0.02
|
||||
effective_input: Final = 0.0 if override == "input_cost_per_token_priority" and service_tier == "priority" else input_rate
|
||||
cache_rate: Final = 0.0 if override == "cache_read_input_token_cost" else (0.001 if inherit_configured else effective_input)
|
||||
assert completion_cost(
|
||||
model=model,
|
||||
completion_response=ModelResponse(
|
||||
model=model,
|
||||
usage=Usage(
|
||||
prompt_tokens=100,
|
||||
completion_tokens=3,
|
||||
total_tokens=103,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=80),
|
||||
),
|
||||
),
|
||||
custom_llm_provider="bedrock",
|
||||
custom_pricing=True,
|
||||
router_model_id="unmapped-profile-deployment",
|
||||
service_tier=service_tier,
|
||||
) == pytest.approx(20 * effective_input + 80 * cache_rate + 3 * output_rate)
|
||||
if known_model:
|
||||
assert {
|
||||
key: value for key, value in litellm.model_cost[model].items() if key in configured_prices
|
||||
} == configured_prices
|
||||
|
||||
|
||||
def test_partial_custom_pricing_inherits_builtin_cache_pricing():
|
||||
"""A deployment that overrides only input/output cost on a cache-supporting
|
||||
model must still bill cache_read and cache_creation tokens. Before the
|
||||
|
|
@ -2191,14 +2363,7 @@ def test_a_reload_rebuilds_exactly_what_a_fresh_boot_registered() -> None:
|
|||
)
|
||||
|
||||
rebuilt = litellm.model_cost["priced-id"]
|
||||
assert at_boot.items() <= rebuilt.items(), (
|
||||
f"the rebuild changed or dropped a field the boot registration wrote: "
|
||||
f"{ {k: (v, rebuilt.get(k)) for k, v in at_boot.items() if rebuilt.get(k) != v} }"
|
||||
)
|
||||
assert {field: rebuilt[field] for field in set(rebuilt) - set(at_boot)} == {
|
||||
"db_model": False,
|
||||
"member_auto_router": False,
|
||||
}
|
||||
assert at_boot == rebuilt
|
||||
assert "member_auto_router" not in litellm.model_cost["gpt-4o"]
|
||||
assert router.model_list
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -763,6 +763,8 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"cache_creation_input_token_cost": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_1hr": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_32k_tokens": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_100k_tokens": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_100k_tokens_batches": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_128k_tokens": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_200k_tokens": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_256k_tokens": {"type": "number"},
|
||||
|
|
@ -773,11 +775,22 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"cache_creation_input_token_cost_above_200k_tokens_batches": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_272k_tokens_batches": {"type": "number"},
|
||||
"cache_creation_input_token_cost_batches": {"type": "number"},
|
||||
"cache_creation_input_token_cost_decisions": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_272k_tokens_decisions": {"type": "number"},
|
||||
"cache_read_input_token_cost_decisions": {"type": "number"},
|
||||
"cache_read_input_token_cost_above_272k_tokens_decisions": {"type": "number"},
|
||||
"input_cost_per_token_decisions": {"type": "number"},
|
||||
"input_cost_per_token_above_272k_tokens_decisions": {"type": "number"},
|
||||
"output_cost_per_token_decisions": {"type": "number"},
|
||||
"output_cost_per_token_above_272k_tokens_decisions": {"type": "number"},
|
||||
"cache_creation_input_token_cost_flex": {"type": "number"},
|
||||
"cache_creation_input_token_cost_priority": {"type": "number"},
|
||||
"cache_creation_input_token_cost_ultrafast": {"type": "number"},
|
||||
"cache_read_input_token_cost": {"type": "number"},
|
||||
"explicit_cache_read_input_token_cost": {"type": "number"},
|
||||
"cache_read_input_token_cost_above_32k_tokens": {"type": "number"},
|
||||
"cache_read_input_token_cost_above_100k_tokens": {"type": "number"},
|
||||
"cache_read_input_token_cost_above_100k_tokens_batches": {"type": "number"},
|
||||
"cache_read_input_token_cost_above_128k_tokens": {"type": "number"},
|
||||
"cache_read_input_token_cost_above_200k_tokens": {"type": "number"},
|
||||
"cache_read_input_token_cost_above_200k_tokens_batches": {"type": "number"},
|
||||
|
|
@ -789,6 +802,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"input_cost_per_token_above_272k_tokens_ultrafast": {"type": "number"},
|
||||
"cache_read_input_token_cost_batches": {"type": "number"},
|
||||
"cache_read_input_token_cost_above_272k_tokens_batches": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_1hr_above_100k_tokens": {"type": "number"},
|
||||
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": {"type": "number"},
|
||||
"cache_read_input_audio_token_cost": {"type": "number"},
|
||||
"cache_read_input_image_token_cost": {"type": "number"},
|
||||
|
|
@ -805,6 +819,8 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"input_cost_per_image_above_128k_tokens": {"type": "number"},
|
||||
"input_cost_per_video_token": {"type": "number"},
|
||||
"input_cost_per_token_above_32k_tokens": {"type": "number"},
|
||||
"input_cost_per_token_above_100k_tokens": {"type": "number"},
|
||||
"input_cost_per_token_above_100k_tokens_batches": {"type": "number"},
|
||||
"input_cost_per_token_above_200k_tokens": {"type": "number"},
|
||||
"input_cost_per_token_above_200k_tokens_batches": {"type": "number"},
|
||||
"input_cost_per_token_above_256k_tokens": {"type": "number"},
|
||||
|
|
@ -842,6 +858,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"input_cost_per_request": {"type": "number"},
|
||||
"cost_per_second": {"type": "number"},
|
||||
"input_cost_per_second": {"type": "number"},
|
||||
"input_cost_per_text_message": {"type": "number"},
|
||||
"input_cost_per_token": {"type": "number"},
|
||||
"input_cost_per_token_above_128k_tokens": {"type": "number"},
|
||||
"input_cost_per_audio_token_batches": {"type": "number"},
|
||||
|
|
@ -878,6 +895,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"image_edit",
|
||||
"embedding",
|
||||
"evaluation",
|
||||
"extraction",
|
||||
"guardrail",
|
||||
"image_generation",
|
||||
"video_generation",
|
||||
|
|
@ -902,6 +920,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"output_cost_per_image_2K": {"type": "number"},
|
||||
"output_cost_per_image_4K": {"type": "number"},
|
||||
"output_cost_per_image_token": {"type": "number"},
|
||||
"output_cost_per_image_token_batches": {"type": "number"},
|
||||
"output_cost_per_video_token": {"type": "number"},
|
||||
"output_cost_per_pixel": {"type": "number"},
|
||||
"output_cost_per_second": {"type": "number"},
|
||||
|
|
@ -913,6 +932,8 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"output_cost_per_second_4k": {"type": "number"},
|
||||
"output_cost_per_token": {"type": "number"},
|
||||
"output_cost_per_token_above_32k_tokens": {"type": "number"},
|
||||
"output_cost_per_token_above_100k_tokens": {"type": "number"},
|
||||
"output_cost_per_token_above_100k_tokens_batches": {"type": "number"},
|
||||
"output_cost_per_token_above_128k_tokens": {"type": "number"},
|
||||
"output_cost_per_token_above_200k_tokens": {"type": "number"},
|
||||
"output_cost_per_token_above_200k_tokens_batches": {"type": "number"},
|
||||
|
|
@ -952,10 +973,9 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"supports_tool_choice": {"type": "boolean"},
|
||||
"supports_tool_search": {"type": "boolean"},
|
||||
"supports_video_input": {"type": "boolean"},
|
||||
"supports_box_questions": {"type": "boolean"},
|
||||
"supports_vision": {"type": "boolean"},
|
||||
"supports_web_search": {"type": "boolean"},
|
||||
"supports_bedrock_runtime_chat_completions_tools_with_reasoning": {"type": "boolean"},
|
||||
"supports_bedrock_runtime_chat_completions_response_format": {"type": "boolean"},
|
||||
"token_count_framing": {"type": "integer"},
|
||||
"token_count_model": {
|
||||
"type": "object",
|
||||
|
|
@ -963,6 +983,10 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"required": ["model"],
|
||||
},
|
||||
"token_count_tokenizer": {"type": "string", "enum": ["kimi_k3"]},
|
||||
"supports_bedrock_runtime_chat_completions_tools_with_reasoning": {"type": "boolean"},
|
||||
"supports_bedrock_runtime_chat_completions_response_format": {"type": "boolean"},
|
||||
"supports_bedrock_runtime_chat_completions_inline_reasoning": {"type": "boolean"},
|
||||
"supports_bedrock_converse_tool_result_images": {"type": "boolean"},
|
||||
"supports_url_context": {"type": "boolean"},
|
||||
"supports_multimodal": {"type": "boolean"},
|
||||
"uses_embed_content": {"type": "boolean"},
|
||||
|
|
@ -1005,6 +1029,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"enum": ["low", "medium", "high", "max", "xhigh"],
|
||||
},
|
||||
"bedrock_converse_supports_strict_tools": {"type": "boolean"},
|
||||
"supports_regex_lookaround": {"type": "boolean"},
|
||||
"tpm": {"type": "number"},
|
||||
"supported_endpoints": {
|
||||
"type": "array",
|
||||
|
|
@ -1086,6 +1111,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"supports_native_streaming": {"type": "boolean"},
|
||||
"supports_image_size": {"type": "boolean"},
|
||||
"supports_native_structured_output": {"type": "boolean"},
|
||||
"supports_bedrock_native_structured_output": {"type": "boolean"},
|
||||
"use_openai_responses_path": {"type": "boolean"},
|
||||
"off_peak_pricing": {
|
||||
"type": "object",
|
||||
|
|
@ -4260,7 +4286,11 @@ async def test_builtin_string_callback_registers_when_subclass_already_active(
|
|||
assert any(type(cb) is S3Logger for cb in litellm._async_success_callback)
|
||||
|
||||
|
||||
def test_reapply_runtime_registrations_replays_register_model_overrides(monkeypatch):
|
||||
@pytest.mark.parametrize("rate", (0.0, 0.000123))
|
||||
@pytest.mark.parametrize("updated_rate", (None, 0.0, 0.000456))
|
||||
def test_reapply_runtime_registrations_replays_register_model_overrides(
|
||||
monkeypatch: pytest.MonkeyPatch, rate: float, updated_rate: float | None
|
||||
) -> None:
|
||||
"""
|
||||
register_model is the documented way to override pricing for a model. A
|
||||
price-data reload swaps litellm.model_cost for a freshly fetched catalog,
|
||||
|
|
@ -4291,11 +4321,17 @@ def test_reapply_runtime_registrations_replays_register_model_overrides(monkeypa
|
|||
"openai/gpt-4o": {
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"input_cost_per_token": 0.000123,
|
||||
"input_cost_per_token": rate,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
litellm.register_model({"openai/gpt-4o": {"supports_function_calling": True, "input_cost_per_token": None}})
|
||||
litellm.register_model(
|
||||
{"openai/gpt-4o": {"input_cost_per_token": str(updated_rate) if updated_rate is not None else None}}
|
||||
)
|
||||
litellm.register_model({"openai/gpt-4o": {"supports_function_calling": False}})
|
||||
|
||||
litellm.model_cost = {
|
||||
"openai/gpt-4o": {
|
||||
"litellm_provider": "openai",
|
||||
|
|
@ -4307,7 +4343,9 @@ def test_reapply_runtime_registrations_replays_register_model_overrides(monkeypa
|
|||
_invalidate_model_cost_lowercase_map()
|
||||
reapply_runtime_model_cost_registrations()
|
||||
|
||||
assert litellm.model_cost["openai/gpt-4o"]["input_cost_per_token"] == 0.000123
|
||||
expected_rate: Final = rate if updated_rate is None else updated_rate
|
||||
assert litellm.model_cost["openai/gpt-4o"]["input_cost_per_token"] == expected_rate
|
||||
assert litellm.model_cost["openai/gpt-4o"]["supports_function_calling"] is False
|
||||
assert litellm.model_cost["openai/gpt-4o"]["max_input_tokens"] == 4242
|
||||
finally:
|
||||
litellm.model_cost = saved_model_cost
|
||||
|
|
|
|||
36
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
36
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -34593,8 +34593,14 @@ export interface components {
|
|||
cache_creation_input_audio_token_cost?: number | null;
|
||||
/** Cache Creation Input Token Cost */
|
||||
cache_creation_input_token_cost?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 100K Tokens */
|
||||
cache_creation_input_token_cost_above_100k_tokens?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 100K Tokens Batches */
|
||||
cache_creation_input_token_cost_above_100k_tokens_batches?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 1Hr */
|
||||
cache_creation_input_token_cost_above_1hr?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 1Hr Above 100K Tokens */
|
||||
cache_creation_input_token_cost_above_1hr_above_100k_tokens?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 200K Tokens */
|
||||
cache_creation_input_token_cost_above_200k_tokens?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 200K Tokens Batches */
|
||||
|
|
@ -34623,6 +34629,10 @@ export interface components {
|
|||
cache_read_input_image_token_cost?: number | null;
|
||||
/** Cache Read Input Token Cost */
|
||||
cache_read_input_token_cost?: number | null;
|
||||
/** Cache Read Input Token Cost Above 100K Tokens */
|
||||
cache_read_input_token_cost_above_100k_tokens?: number | null;
|
||||
/** Cache Read Input Token Cost Above 100K Tokens Batches */
|
||||
cache_read_input_token_cost_above_100k_tokens_batches?: number | null;
|
||||
/** Cache Read Input Token Cost Above 200K Tokens */
|
||||
cache_read_input_token_cost_above_200k_tokens?: number | null;
|
||||
/** Cache Read Input Token Cost Above 200K Tokens Batches */
|
||||
|
|
@ -34707,6 +34717,10 @@ export interface components {
|
|||
input_cost_per_second?: number | null;
|
||||
/** Input Cost Per Token */
|
||||
input_cost_per_token?: number | null;
|
||||
/** Input Cost Per Token Above 100K Tokens */
|
||||
input_cost_per_token_above_100k_tokens?: number | null;
|
||||
/** Input Cost Per Token Above 100K Tokens Batches */
|
||||
input_cost_per_token_above_100k_tokens_batches?: number | null;
|
||||
/** Input Cost Per Token Above 128K Tokens */
|
||||
input_cost_per_token_above_128k_tokens?: number | null;
|
||||
/** Input Cost Per Token Above 200K Tokens */
|
||||
|
|
@ -34836,6 +34850,10 @@ export interface components {
|
|||
output_cost_per_second_768p?: number | null;
|
||||
/** Output Cost Per Token */
|
||||
output_cost_per_token?: number | null;
|
||||
/** Output Cost Per Token Above 100K Tokens */
|
||||
output_cost_per_token_above_100k_tokens?: number | null;
|
||||
/** Output Cost Per Token Above 100K Tokens Batches */
|
||||
output_cost_per_token_above_100k_tokens_batches?: number | null;
|
||||
/** Output Cost Per Token Above 128K Tokens */
|
||||
output_cost_per_token_above_128k_tokens?: number | null;
|
||||
/** Output Cost Per Token Above 200K Tokens */
|
||||
|
|
@ -49083,8 +49101,14 @@ export interface components {
|
|||
cache_creation_input_audio_token_cost?: number | null;
|
||||
/** Cache Creation Input Token Cost */
|
||||
cache_creation_input_token_cost?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 100K Tokens */
|
||||
cache_creation_input_token_cost_above_100k_tokens?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 100K Tokens Batches */
|
||||
cache_creation_input_token_cost_above_100k_tokens_batches?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 1Hr */
|
||||
cache_creation_input_token_cost_above_1hr?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 1Hr Above 100K Tokens */
|
||||
cache_creation_input_token_cost_above_1hr_above_100k_tokens?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 200K Tokens */
|
||||
cache_creation_input_token_cost_above_200k_tokens?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 200K Tokens Batches */
|
||||
|
|
@ -49113,6 +49137,10 @@ export interface components {
|
|||
cache_read_input_image_token_cost?: number | null;
|
||||
/** Cache Read Input Token Cost */
|
||||
cache_read_input_token_cost?: number | null;
|
||||
/** Cache Read Input Token Cost Above 100K Tokens */
|
||||
cache_read_input_token_cost_above_100k_tokens?: number | null;
|
||||
/** Cache Read Input Token Cost Above 100K Tokens Batches */
|
||||
cache_read_input_token_cost_above_100k_tokens_batches?: number | null;
|
||||
/** Cache Read Input Token Cost Above 200K Tokens */
|
||||
cache_read_input_token_cost_above_200k_tokens?: number | null;
|
||||
/** Cache Read Input Token Cost Above 200K Tokens Batches */
|
||||
|
|
@ -49197,6 +49225,10 @@ export interface components {
|
|||
input_cost_per_second?: number | null;
|
||||
/** Input Cost Per Token */
|
||||
input_cost_per_token?: number | null;
|
||||
/** Input Cost Per Token Above 100K Tokens */
|
||||
input_cost_per_token_above_100k_tokens?: number | null;
|
||||
/** Input Cost Per Token Above 100K Tokens Batches */
|
||||
input_cost_per_token_above_100k_tokens_batches?: number | null;
|
||||
/** Input Cost Per Token Above 128K Tokens */
|
||||
input_cost_per_token_above_128k_tokens?: number | null;
|
||||
/** Input Cost Per Token Above 200K Tokens */
|
||||
|
|
@ -49326,6 +49358,10 @@ export interface components {
|
|||
output_cost_per_second_768p?: number | null;
|
||||
/** Output Cost Per Token */
|
||||
output_cost_per_token?: number | null;
|
||||
/** Output Cost Per Token Above 100K Tokens */
|
||||
output_cost_per_token_above_100k_tokens?: number | null;
|
||||
/** Output Cost Per Token Above 100K Tokens Batches */
|
||||
output_cost_per_token_above_100k_tokens_batches?: number | null;
|
||||
/** Output Cost Per Token Above 128K Tokens */
|
||||
output_cost_per_token_above_128k_tokens?: number | null;
|
||||
/** Output Cost Per Token Above 200K Tokens */
|
||||
|
|
|
|||
261
uv.lock
generated
261
uv.lock
generated
|
|
@ -85,7 +85,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.14.4"
|
||||
version = "3.14.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
|
|
@ -98,127 +98,127 @@ dependencies = [
|
|||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/93/2f/6a91adaa2dc26877d6ed2f54c0370c8910f019db7d77c5c6a194611e93ea/aiohttp-3.14.4.tar.gz", hash = "sha256:831fc5bd39ec2517851e348f613ddb5447a47cf4b71cb09845af7ad7ed45d8f9", size = 8077839, upload-time = "2026-10-05T17:43:48.309Z" }
|
||||
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/b3/08/290e4be6e3ed239751c446505c324406a4ae8a82a15009a12a3c550f18d9/aiohttp-3.14.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dea3b3653c1d0d6babd8ce09cb9e5a7776864315d9cfb1c569f926389874a261", size = 834853, upload-time = "2026-10-05T17:37:45.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/81/ed5e04508ba1fcf9a34e0449c78009956e8256f62ebef708e2948594cf94/aiohttp-3.14.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3099a389e5b5900fc79c6f613015ff3f8fa06a519744e6001cc68a31ba418cac", size = 560957, upload-time = "2026-10-05T17:37:47.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/28/1a91411c53704db0481cc64b0f0c9465aa9d738856abdcaa3a5bbbe18180/aiohttp-3.14.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:70247156fc76e92919f9aacb820556cb30289a54784299a31714d030bb604d84", size = 553475, upload-time = "2026-10-05T17:37:49.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/2e/1038f3fa8832f9f4bed0f9f751f70dbc52bc4a09c66788f97bbfb0ce3d88/aiohttp-3.14.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdf2d2a6ad402797422949f4a200a60d43e3a021695c8a7804aa1d5bbef22652", size = 1845211, upload-time = "2026-10-05T17:37:50.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/54/e2253f17604c5c1c69a05e3c12400b51cd6a5fb32d7175592114d8c7a470/aiohttp-3.14.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ba835fad8d4b5eaea0d34f9461cf499737a720a5f8bc6768d2f6d98a1d3ba8b5", size = 1814629, upload-time = "2026-10-05T17:37:52.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ef/7bd1e45db925376720555d7268c2d642b113105263d37a522e41e1ba3a6c/aiohttp-3.14.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d4415ca5c9c8b24bc2f5733f870d4eab4929e680203559ba60131dd1a27852a", size = 1898528, upload-time = "2026-10-05T17:37:54.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/40/26d09668e7e0cadc16027566765aa4ce26e8a41276bad6764848c8b04d11/aiohttp-3.14.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6757e02c2c2b0d7985f57be87cf4a7c5b35708f849e1feaea3f77656ddb9dff", size = 2024385, upload-time = "2026-10-05T17:37:55.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/38/0f1c4aab1cc9518e1bf874e2481e59e541b3e32330b9542e52bb2712f86f/aiohttp-3.14.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a950bd8418ef54eccf5f9f517f08e37c1adab4bc50a470a0422ee1cc1032e29", size = 1859130, upload-time = "2026-10-05T17:37:57.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/98/09d5998ba4b5fe9f0cb97d794fab9d1b3d8245c5ec6a5fb84e41a8cec142/aiohttp-3.14.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34cf6f2fe4fc1d7183e8cc0d8c48bbdda828a740bc372d8278126149714d343c", size = 1714768, upload-time = "2026-10-05T17:37:58.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/63/39d01a74d525d6443084d47ad4aa8c719f44a030149d03566f3761527465/aiohttp-3.14.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bc0fbe4f79c3b156d754dd062718ffe404c3b31ca878d9d2b88ecdbc72ef8369", size = 1813228, upload-time = "2026-10-05T17:38:00.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/6b/183d7b969bdb7892e86af74147b4afedbb4e03b8cad5006e41aa2034c288/aiohttp-3.14.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d3d50a5a07a2b31a4369b86dd4a3acf3c2577c45091dbd5e884b198dec56480b", size = 1831844, upload-time = "2026-10-05T17:38:02.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/be/4c6df02b62083efd3f008974cf9016a4afd2023f5a9d2c3fd55499fcbac6/aiohttp-3.14.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:de15c46a63f46da881d219e44a04778c40114c756e5575b5ea38143b20eb3713", size = 1868738, upload-time = "2026-10-05T17:38:03.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/21/406be8b0cf1105a35a6a6da16a95796be39b354e29bfca0d1626d822835f/aiohttp-3.14.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:caa4c0fc9b25e8c679baac68337e9c00deb6baf6601fe6f07e8511469cc1ceba", size = 1700006, upload-time = "2026-10-05T17:38:05.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/e6/beac98020ea0885ea1ee3b2ef59ec952405590fe2ccef2fa4ef7614c3c2b/aiohttp-3.14.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3647f8c090632c70abb1e80826e4ee24faea3f895f0283b60e91a0b17aea5e34", size = 1889997, upload-time = "2026-10-05T17:38:07.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/89/d608dd9571988a529c7fead5bc79ad36b56fd52bad7941f40774acbc87e9/aiohttp-3.14.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3f3e8030169843fb652a60a3d32c206e03f260198aa398985a48b41cd7e42f0a", size = 1844619, upload-time = "2026-10-05T17:38:10.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7d/7f51e6338d2c74b75173ee66fcb50d778d5407c32f34e76ef1c4b25d0d75/aiohttp-3.14.4-cp310-cp310-win32.whl", hash = "sha256:a1205cbdddfca1428129bb6d23e0b292b09e681df951fa828145d83a63ffc0ca", size = 489598, upload-time = "2026-10-05T17:38:11.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2a/3434e450a4395fc07f3070d6ac24553f0b170b173e949216b1723395028c/aiohttp-3.14.4-cp310-cp310-win_amd64.whl", hash = "sha256:e90980e0454a043f81b435309a089ac5deab883779557eb58d004f33990f61e4", size = 512204, upload-time = "2026-10-05T17:38:13.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/c0/1d89303cf1bea5418268c1cb82b872661bad027e5fd785c2d30d0a31ee6f/aiohttp-3.14.4-cp310-cp310-win_arm64.whl", hash = "sha256:e63eecf9a4b670a24055d5c0486128af16f83b02d9817abd685d3238940f8752", size = 492725, upload-time = "2026-10-05T17:38:14.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e3/998dc75634ecd2a24f33bab480b57313059d55bcadff8825a46b7c80a8af/aiohttp-3.14.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb0093ca0817019d539f0d27bdf98e823944e05bb7efd9d2337a45b92e4d1b06", size = 832892, upload-time = "2026-10-05T17:38:16.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/c9/9bacc26397854440d169f70ae00ea51721f504611d3a64cf043643792c32/aiohttp-3.14.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f482dc9c8309801b4259dd218609d3dae0a7bcdedeac56d90500eabaa0c23465", size = 559893, upload-time = "2026-10-05T17:38:18.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f3/90c5856014328a8b1e9a753de794edea282182b776ff65f0602ad4ffa520/aiohttp-3.14.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aa800bb7c1d00e166493931f2ddacb982db913292503dc2f4884db1f2bc5f105", size = 552850, upload-time = "2026-10-05T17:38:20.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/88/e71c29ce6daa78fcee0feb1992f0fdc408f09f42c3311b1f1c68c36f16ea/aiohttp-3.14.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d90e4dae71b26597f6d1fd12af7d092ae67551edf0e524b73e0687214b79ee33", size = 1928941, upload-time = "2026-10-05T17:38:22.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/4f/f0f3bebfa521a2ce5fd4302c937701cbc50cd6d908beaa597388639b0b98/aiohttp-3.14.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b681054b8c3b86aa6a0384b6ad7f5cb78dccbabab1f4a3a6793f98ad1378959", size = 1882092, upload-time = "2026-10-05T17:38:24.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/0d/5df041fd25dceb4f4cafbbee49bb2c1e2d54a91aedecaf03b77cbb404c44/aiohttp-3.14.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dfe80b41bcf8d80d2125fb99a171adeb86db2fcb35c1a68e0226acba0ba7f03", size = 1986051, upload-time = "2026-10-05T17:38:26.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/07/e95eddafffef7c3a74446f67ac4e477a49cec2c78f2ab6c86d1acfd90d2d/aiohttp-3.14.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:71e4a59c6a8c5a6ae8b0b696c32635913e8f526cc01448762eaf5a3c59df8a4f", size = 2107840, upload-time = "2026-10-05T17:38:28.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/6c/1f83990fea249dcb10f48086726953546efc9199c96b6be7114532ac361f/aiohttp-3.14.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f887c3b7fb3058fc1ccd917f52078540c18d938d12018cb9edd64b6811d20c57", size = 1930721, upload-time = "2026-10-05T17:38:30.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d1/48184a186ea26df4f81dd466144dcab7f9cdd1e131bfee0f0e41278c6f0d/aiohttp-3.14.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0da4525e9ba145a11617d2cd7e44fe1acaf471f9c72a0cad28e72879c1020fbb", size = 1774508, upload-time = "2026-10-05T17:38:31.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ef/a9ec758c6d3850453838c31b56d1e4499eb47c80883753a12dcb203f9afa/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7e8d3c5caa44fdbee3eba1aa1417aa58c74c95d4089fe3c451911117fcbb2f3", size = 1902311, upload-time = "2026-10-05T17:38:33.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/49/9a2be54900f2dea6e63bbf9628fced604686a2e301130ccde0b1aa2e7e53/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:09092cf906c18c824b6b16881da5a89cc9cfa9b15344e496bcf880e9045bee33", size = 1912107, upload-time = "2026-10-05T17:38:35.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2b/151254abfc0fe5cbb8e6c41ad04d52122db0af176fb31dbf3bc44e081bda/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:dfd144837e92264878bba6d3a7ffa8206668b21e87748740afd45d193e48c87e", size = 1960207, upload-time = "2026-10-05T17:38:37.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/99/92c8dce336ac5784755a3144623528516f2e3b6b4871d02cad5a0642ae66/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9413cffe4e0d654b9b524f9c99dde8cbdb37e0ed9a2c4edd2e63adbac6b374e8", size = 1760168, upload-time = "2026-10-05T17:38:39.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/3e/f05fd8d1a9595730db8d522882eea76429b9be2ed5337609f7c6e3d9d49c/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f50d719f97ba488dfb8e306cf8c8cc172ff0683ec0af53917b288229aa555ba5", size = 1979946, upload-time = "2026-10-05T17:38:40.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/14/8941aa73ffceabc2a0eb26878b4f1b2958fb7b5358f97cc49b1c00ed9258/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:68ed4824d96b7afec1f3a8d7fba190fe282718041c5772e20298003e1e3e5d66", size = 1923375, upload-time = "2026-10-05T17:38:43.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/b5/ef275b63c8bfe0a21e7e8d3ef0e67c4745ed403f1894fe5205057cedd3b9/aiohttp-3.14.4-cp311-cp311-win32.whl", hash = "sha256:d7a41d1427136828e6b11d3f3fd194d4357d5205a5b610be7ae07b1ba52ab974", size = 488414, upload-time = "2026-10-05T17:38:44.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/0b/21bdd70a1e072222d9dbd96bed4fbed26f7c46c5a20af11b78d32fd049d6/aiohttp-3.14.4-cp311-cp311-win_amd64.whl", hash = "sha256:2efbdb87e79d596325c4eefaef0f4495d701145e882351310ebc538e854dc7a3", size = 512755, upload-time = "2026-10-05T17:38:47.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/4c/bb324a7d20a9598187bb028ca8d6bcf66c63589c8632420919dd576964ed/aiohttp-3.14.4-cp311-cp311-win_arm64.whl", hash = "sha256:ac6e6f90d9360e460c873f945f11dc8698db0a71fec6612823f1aaaab2c11faa", size = 492960, upload-time = "2026-10-05T17:38:49.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/02/db5935d45347fa8ede56b777930740fb0e72bbb4473ddb6ff8541f6f2afa/aiohttp-3.14.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:55b0b335982b0db7117dff7f6a8e3aad43c4105f9e392ca04f9fc8d958a95cce", size = 817689, upload-time = "2026-10-05T17:38:50.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/56/f00aa46127e6342f019d567853eeeb932e04fb9b9424b5fb981ad68f4178/aiohttp-3.14.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d0d8e435582b0d53009dac99e26a3d2be932ed7c9dbfcfc70f3fce1cf943bd5", size = 549599, upload-time = "2026-10-05T17:38:52.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/bc/825c09b09962253a2103e4a88c04374ee939c0c8ea31c270daa0308efb07/aiohttp-3.14.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf734016932d68e1324cfb638cac7a0866a83933a33badf15e836a7e24efb8c0", size = 548132, upload-time = "2026-10-05T17:38:54.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/80/8a1bc8036540fe7cb38219cc5c0aa67d32e9068165725e8199ea8200b08b/aiohttp-3.14.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:322e3d741b3133bcbb981a41d4f9b8ac3545c18de49c662c2b1f4e1c770b98ad", size = 1898485, upload-time = "2026-10-05T17:38:56.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fe/65b464d8520ace0d65b4ca69bc9abe175f842c8ce8d1522d363d9669903f/aiohttp-3.14.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15a3bb5f90a4e515071f3617a45dfe26560beb834a6b6e108cdf4bc9e9719ec3", size = 1875334, upload-time = "2026-10-05T17:38:58.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/51/9c583be502720e927560a8414f4dfb7c7de41945e1a30ccf7003b93c6f85/aiohttp-3.14.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c044e52df1a466af82cad3518c92ea80cc4ddcfea86966ff083bb34bab29bf4", size = 1937772, upload-time = "2026-10-05T17:39:00.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/3d/b56512df7ea9cbb5f59099a6b375088dd578356a5a3fb27198ab82e19832/aiohttp-3.14.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d613d7d51bf06fe9a5e3aab86ce06d835bb0ceb1a31ea9e8766df4a403ae62a6", size = 2066220, upload-time = "2026-10-05T17:39:02.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/6c/a7a42701d9ea319140a3d44a24ec72a00b0b46d1b899b26d8a705e65bdd8/aiohttp-3.14.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5e93ab6e6330dce40f5b43996f1fc5f2543296d263a5e4b59ff09e73870b7d8", size = 1922895, upload-time = "2026-10-05T17:39:04.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/04/f8337bd824ca3fe3968d34dc5037ace1c4872551c4621f8266239311d301/aiohttp-3.14.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:20134dc8e68c67e7478124d516449428684c0545b2d3faa575691c8bcbd56678", size = 1712589, upload-time = "2026-10-05T17:39:06.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/a6/d20e2c940b592d7c4a21690889dbff64fe0ca3facd9190696368694e1488/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f0daef5ef012369dddc391c61f604111424aa9a96313a69fb899018b09d1b01e", size = 1862251, upload-time = "2026-10-05T17:39:08.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e4/d50edd5ae5d024b380a8e892e333840bb22967a9eb9c8492108a403f678a/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:19290a108de0718b73bc69b86e788975c6a59855c671ebe2a98a07a6f968328f", size = 1895155, upload-time = "2026-10-05T17:39:10.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d3/f36f2931f807ccf8437ec15e8f8bfaa4748d4792639401cc767a866abaa0/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1494877625baeffab669f4a25a09ec4eea721b49e35363081a2a8e4231fe141f", size = 1906623, upload-time = "2026-10-05T17:39:12.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/0a/94821b711493142bf5e11c27b0d7502776778f7792c77ecc6647357d20c1/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a779ce4bf099ce4ad2cdfed62f1ca536d435f53237c54ff40172fddada24d1b5", size = 1698932, upload-time = "2026-10-05T17:39:14.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/76/3d5a0d28362f0053c1c457024749911932481e8fd105fddf8305b1f93c81/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0b4cec9fca876e2d4f4c6cb32b177390132d04618867689f43fefabf3f5c3980", size = 1934528, upload-time = "2026-10-05T17:39:16.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/51/9784a21dc05d2e0f6acaf864f16db53dab0fbd7b0753cef168601eacb012/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:217707313e23aedde9bea67cf8365c7b138b549ae179121ef3f476cf67e3173a", size = 1901984, upload-time = "2026-10-05T17:39:18.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/4f/f98c88703e6d620243aa08473a946ca4e3756b6288690e38f0d9f9c1d007/aiohttp-3.14.4-cp312-cp312-win32.whl", hash = "sha256:18a9fb9a3f6e6e63f4d27f9adeef313a2d3c69a84d31170a844623824e722534", size = 480278, upload-time = "2026-10-05T17:39:20.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/bc/8555013f84b53e4fdde423131b641af50e822c6d7d9118b24c52b31f6900/aiohttp-3.14.4-cp312-cp312-win_amd64.whl", hash = "sha256:69dcb02d33dfe415d5ee342cc63b7d320efcaf4b761701c7bd7bfb91c715481a", size = 506317, upload-time = "2026-10-05T17:39:22.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/3f/ca5bad7bfdaa7ddca053a044a1c7c0d4f3ad9d1f12f96dcab10bfb9c50e7/aiohttp-3.14.4-cp312-cp312-win_arm64.whl", hash = "sha256:d36b0263e7c2fbf1750b9f35d4e7e48b0442d8c45e4a89e3e74168821fb014bb", size = 487683, upload-time = "2026-10-05T17:39:24.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/ec/7fee140da700ec3f5aa5e0fd2319f67be5e86de197e2384d27e5e6209e0e/aiohttp-3.14.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:264b5a7568306590c44ae4a0237ad1715e35d447ea44ebf324e4c8fd9f78f67a", size = 539114, upload-time = "2026-10-05T17:39:26.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/04/930c01ce9e186e787046433d7dc5b45b188885f8406c14b58435b4431974/aiohttp-3.14.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:17e5d7c7775d8dd8e894c7ef6ebfa26509de36868a9a6c7c4fcfaf6a1bf43d60", size = 553514, upload-time = "2026-10-05T17:39:28.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/d7/983e3cea81c07e95f35d34da4d1e182b5c05489c49b7560b198a83f0da79/aiohttp-3.14.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e91dc8fe9cbd052d16f7f1267e285cf8bbac4d2194bd5741f05ef201c77ed09e", size = 521381, upload-time = "2026-10-05T17:39:30.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/2d/80baed11659093883f51245755105557d3253e45bd0b693d70df0059d68b/aiohttp-3.14.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8df6157d31703972aec9e3b93963f039ff06318301e30b007c3e941ff8a7ac42", size = 528347, upload-time = "2026-10-05T17:39:32.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/18/bfda255c311096c08dab26dc65f3d219f090df37dc5f5f6345f8c3aefc1f/aiohttp-3.14.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:220912b351549dc736c59d104dc443fa615753102bb778a4e98f86e4d94bff6a", size = 539914, upload-time = "2026-10-05T17:39:34.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/fc/3b6ca6117c0c42481d719411fff7b5d3970ebd906543d3dc1ba40fe2462f/aiohttp-3.14.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2efa42b39bb3f524d5eca3760639b584afdd1143a6b047fda29910ffb75e4993", size = 813997, upload-time = "2026-10-05T17:39:36.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/f6/86e3e02a9712aadc1ae0a1076bb2d1adf3d9addfe5542a28190c8f9b6d9b/aiohttp-3.14.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e7356059c8262d2c1fd95242e2b7b70fbc6eee3135bf470049cbec3e968b5b0", size = 548058, upload-time = "2026-10-05T17:39:38.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/7e/6b9c2e271419d40ca76589613b3c7ab185fd3050a407bd2565b6aa46f695/aiohttp-3.14.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fac247d0cc956d732df1211ac2d8a9999ec8dc1a92a90401de9d52e4ead546f7", size = 545933, upload-time = "2026-10-05T17:39:40.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/fc/33d6e24a5f8ac32423230088c415e2a1c6efee23d902b62f20506631e40b/aiohttp-3.14.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2eb0f8b03b4f2bd154ed8cf7c1a119a1ad04808c258bf58f90138c83b8c6d5", size = 1888265, upload-time = "2026-10-05T17:39:42.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/e8/2eca1d468d83a514621d1280f9992d08649191db2028c9236b4edfa7c723/aiohttp-3.14.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbe827d27e0e369fd7bb88ceeaa5b46ea006e73e015b9dd77e3723a53162293", size = 1872300, upload-time = "2026-10-05T17:39:44.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f6/5872983de71b0214acdfb86822f5cca295b39be4832ec589fa224aee0f6c/aiohttp-3.14.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70387146d03af9f047c629b71aa27d9594b3b31ec4c9c91e1a73d0d8bf38e0e", size = 1926213, upload-time = "2026-10-05T17:39:46.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/65/ebfefa11a546c33c717af63c67868408ecf0b33b23a9d75669af95485407/aiohttp-3.14.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d0934926fc65744e2fdd44ce68b2d79d5ee608a1e23f0596b35dd696519bb7c", size = 2056609, upload-time = "2026-10-05T17:39:48.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/c0/70667e82ec041f4122a17afce711bbcf94a71bec5888b69a49aed8817be2/aiohttp-3.14.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff4b366510c733974adb7a4102c094e4a6308e6f369315a4cf14d2d0a619cf82", size = 1914740, upload-time = "2026-10-05T17:39:50.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/86/559e9dfea676206b20737d7ccb5b3716bff5e58d2a8d53682e5301724cd1/aiohttp-3.14.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:85fac7c99e0ac3dcbd6ae1c33774e0603bfc732ddb967a6fce4a731103c1888e", size = 1703390, upload-time = "2026-10-05T17:39:52.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/91/c98897d4cf23e120b2f3980042835cb34c86bbe3c653f3eaacf4bf986349/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef1e9c3b4a2300023a5dc865889ce532cfaf6798ba4ef78fbbefe738a9d23efa", size = 1852642, upload-time = "2026-10-05T17:39:54.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/13/0258f352cb2c236f358a5b57d138e15fa2566e17accc0758d968dbf2ceb6/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:97e65916588be0f952b3307aaa8460c0771fa98dbea2dd27dabc9f5d84d53c3b", size = 1890616, upload-time = "2026-10-05T17:39:56.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/3c/f43e294ce1a06d1234a97bd862c1aa31acfa1a0eea3786c22fd0dc1ea731/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:840775cb39a9424f9edf142133a1192263ac5bd79117ad2a16602cef6a0c1e9d", size = 1896638, upload-time = "2026-10-05T17:39:58.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/f7/ce1e3a85b73d05037d729d3d32da7d6959b9e433435e9abd46c2fb38410f/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:54f9256430ca040d58affbd551513de13a5cff7d065204fc52b9a1b0af09a049", size = 1693003, upload-time = "2026-10-05T17:40:00.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/72/45eb900d6a9ee046728f5589f3d07f5d78f049b5fb84f48881fa4174d5df/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6112d5931bf12c776188cab81b82bbb17d86e2e1381f2ade6edc43e6b240f0cb", size = 1927730, upload-time = "2026-10-05T17:40:02.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/43/a83ff79dc8375953a1f00b808a21c82590f4e68304fecccdcc42a25fb7d8/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:facc6df39047c4732cbd885bb62e40df20885fcd709fc399e11f81b45ce0fff9", size = 1891177, upload-time = "2026-10-05T17:40:04.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/ba/fbdc41608d4e2fb6084d960a41b392fa82beb5b126c2466de549b5c67fc8/aiohttp-3.14.4-cp313-cp313-win32.whl", hash = "sha256:7968634fa3a967a2bc0b9aeba1d5a1955501dcd7d0de44b96f7ebfb662797735", size = 479943, upload-time = "2026-10-05T17:40:07.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/51/d75629750e705832fd5cd52516a82943ed58b6e09c30e14830f5d87452fa/aiohttp-3.14.4-cp313-cp313-win_amd64.whl", hash = "sha256:c0a894b0265d139cd4c2c8bd4643822cc289a4a5331e21e810881bd000ffb2dc", size = 506209, upload-time = "2026-10-05T17:40:09.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/64/66452d2ffcc13e5e5ee4ba4a968656fa4a98c29ed5ff0205d4fde02b2943/aiohttp-3.14.4-cp313-cp313-win_arm64.whl", hash = "sha256:da16c3037178e72589d91de59536d271dffc92c2be2b47c38a45538cba54fd29", size = 487163, upload-time = "2026-10-05T17:40:11.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/b653b1f969e2e949fe4726af7a8043e8ef117f202bed7757ac8564e077a1/aiohttp-3.14.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:749bfc43bb1df71bbe6ed215044d5412d786698fc5771d2037436ca49622f3c9", size = 540738, upload-time = "2026-10-05T17:40:14.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/b5/eea49a0bb697deffac5165d707ec53a24748d6720b3af77a544107b49573/aiohttp-3.14.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:c113e4b489d711f606ca278cd8dc89179746244ea841aa0638b1b134600afad0", size = 554494, upload-time = "2026-10-05T17:40:16.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/ea/cf8f4efbbfa8572d9965cd43b3c6c84c581aa006772e5ce3ab4020027e9b/aiohttp-3.14.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:50969522bc7022026a2c7968e67da5c789f6b716b988edac55dc630390efd5df", size = 522653, upload-time = "2026-10-05T17:40:18.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/24/9f6721f0b52323789cb1041ffc3afa729462d9925607e733a065b6fc5e17/aiohttp-3.14.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:cbf92cd7a1c800cee244558dd5630abb45c1c1093ef7b0d546a99f526ff7a9eb", size = 529697, upload-time = "2026-10-05T17:40:21.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/a4/399debc3c2ca0ab88a08e3082b4c4f62d0f66978af60243c63f2345ef337/aiohttp-3.14.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fc76be08ff407fba717a769aff04ca1b4bf9ecfdcb5caadef84a563fdbbd146d", size = 540526, upload-time = "2026-10-05T17:40:23.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/97/90379813b18b795bccdeb3a5a85e9b93ac2c69f9c2a5d0faba59da3ad6d1/aiohttp-3.14.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9b416f995b68a921069e6ec61fa07f2ac3ff033d92e97a69f673af89f5bb954d", size = 818866, upload-time = "2026-10-05T17:40:25.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/70/824fd0dac819652f1df895b3f621a75bf0a3050c30e38d8d2316e34ab821/aiohttp-3.14.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2c9585101cfca10e74a07cc0ef2bdc6d90616e6750926b20456f429d0608f740", size = 549666, upload-time = "2026-10-05T17:40:28.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/45/eafb2dd200dd2b770ed25a2dc5731c4ba5d58ab3fa8e2e82494bb946808e/aiohttp-3.14.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3c4dcf611b095e7983421f49d01ffbed7ed574891533d606ea115fc23e7c4e90", size = 548961, upload-time = "2026-10-05T17:40:30.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/bc/6c733a1c71300293471ac7ef1dffd12b5b4dc9a43c53461224b2b8a7f667/aiohttp-3.14.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8f61a9b5fa56c58e3441f9dc64137f786232d40fb89127190d0e0f84e1e5865", size = 1883165, upload-time = "2026-10-05T17:40:33.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/34/885ddbd351679a31141b25e0d1a5e4f67738806e564c858b399709b61239/aiohttp-3.14.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d43e538dfc85b705fd832620d050970ef5613b3869a7169b44a554b9dab9eee9", size = 1844119, upload-time = "2026-10-05T17:40:35.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/ef/0a64dac53c11d569851efb3e7eddb43551df72b2c172ebb34cccc93bcd3f/aiohttp-3.14.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4135c4bb4b7682a7633fadc7170ea969d843f83aa7224e70111a20675105c985", size = 1930222, upload-time = "2026-10-05T17:40:37.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/5f/774792ed8f973e0d234fcb06b0f0bc9f29e9a0a0dd847f6418c2904be7d9/aiohttp-3.14.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9055324f156a94fdf6dc9a825ae60f9a2947b88f591ea81ee80826633de62ae7", size = 2038842, upload-time = "2026-10-05T17:40:39.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/05/e00f35581c97656d9e864edaa03292382c620dd1d9c836a4f73a2279acc4/aiohttp-3.14.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb22c8e4b60385a9b414c70c6e8b2d0f4b23ff7fc4bb572ceb58297b228169f1", size = 1887845, upload-time = "2026-10-05T17:40:42.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/2e/07e3eff5b4c1b082a2eaf0ffb508b409b21ea1ddc9fdc57f3eabb51469a1/aiohttp-3.14.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b6945559a260112742ed58cde2165488d2fc51aeea34afbaa99d9371b43e1b5", size = 1702533, upload-time = "2026-10-05T17:40:44.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/44/816844b7f876e6f8fee4ecb088269fcd92241da712e4814a4d624eda13a9/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f4078a90b174f15efb2c8725e8720c40dbd3b2cf6350dbc085c964a938d9de7", size = 1849058, upload-time = "2026-10-05T17:40:47.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/bd/163242e12464a1f3e58a77e28beca949652ddce84abdd5500a91c70a1e0d/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f433c5654e32f72de3ef074d715eb5c7604eb6d28a5c2f3c9e5056f20fe6b306", size = 1854600, upload-time = "2026-10-05T17:40:50.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/59/5c1ae708a89524eddfd9c727b87cae26b5c50c3c76683b910ac06151ead0/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:871035fe79c636b6bc126ed78df37d72b830dfc751612cb4b1040360edf2719f", size = 1896565, upload-time = "2026-10-05T17:40:52.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/45/95300a7de69241fe47d3f838f3fb582c712782e172d447ff650a013ea673/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2617132524f66338f6c1d218cd16081576aefc40df4f2bcba338a99fbf67fec1", size = 1693092, upload-time = "2026-10-05T17:40:54.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/33/01a8e61d48d0094121092c74125df14c0f8f10ee3149bfe68b6f869409a9/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d8bc326133272deee8ef337581550450e4bb735c0520be893b950a1f5baaf41a", size = 1917073, upload-time = "2026-10-05T17:40:57.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/d6/1a4f24624cda6686d5b927e26d5d1ae3ebb692faf88e93017fab4b391fd0/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ec57223e261b7fa9979c12dbce009ca53439be76cac3af27f860d3e4b1d6f7a", size = 1872656, upload-time = "2026-10-05T17:41:00.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/be/ed286fcfa2d22061ae021b568c790123505cd482df35e23023339ecaed96/aiohttp-3.14.4-cp314-cp314-win32.whl", hash = "sha256:7242043e71fc449a19a47d92f4e9fd1e0ae8d3f88488982381f422bf6d651218", size = 485461, upload-time = "2026-10-05T17:41:02.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/74/e040e6f65f4da305777f1adb3023d47401693489e4a337c983b52d2a0a6b/aiohttp-3.14.4-cp314-cp314-win_amd64.whl", hash = "sha256:019587c7b3a44917dd61bcb312fd5c034f341dc9851b83e7b41b65e08b68ca0f", size = 512147, upload-time = "2026-10-05T17:41:04.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/5e/bddb33ae15fdd5a68a10a986587aa9916c418a712fdd110d3b3ae9b51407/aiohttp-3.14.4-cp314-cp314-win_arm64.whl", hash = "sha256:60dc71d38db0988f37e78f6ad4a010a2f6355cfac3fff5f130cf5ef2231f3f0d", size = 494685, upload-time = "2026-10-05T17:41:07.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/2e/7b884014b40573f919fc3a528e5028bc37b249e31a93928ba61bdd25c960/aiohttp-3.14.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:674f53ab9d2794e2dd767619a3384bf1006f8d075e43c19d449a645f8ff641c2", size = 855696, upload-time = "2026-10-05T17:41:09.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e6/b6e2b4f580ab6ae39241bb9182aca60e6e1915cdad0148faaa04db3f366b/aiohttp-3.14.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6d243221f73e5ac546c2ed9255100423e4bc092b08b944f23498e8cf3096c26c", size = 567931, upload-time = "2026-10-05T17:41:11.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/99/ae8e3a1a18adcbf636839d18b3cccc51007836ccdae5209381c5cf82d1ae/aiohttp-3.14.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0daa016eb74e7888ed5f9ceec6eea3158999c1c4a115ef5cbe88baa8d924f94c", size = 567421, upload-time = "2026-10-05T17:41:13.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/38/c5d4bb58be72b967a4090d2deeb38571f8c992852936c51dbbbeae49db1c/aiohttp-3.14.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70c031a622aa20add05ebd234b90adaf6bf51c1c88cc305ec760721af83308c3", size = 2054173, upload-time = "2026-10-05T17:41:16.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/87/3c8a9c7670f56d478a75c5bb84b336fb61a67b908e36c30a4a96ed8c8e86/aiohttp-3.14.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7df363fa7c38945a896e12c69af4a1a56cbd4152791d9e41fd0bac396b72de38", size = 1918296, upload-time = "2026-10-05T17:41:18.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/97/bd00e499c0a1505b44f5e23b9739034ea9be792ad42537f2894211ba1c07/aiohttp-3.14.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02a508ba5ad91c4d730523584c0adc65882274bba5e37c33bc2f475e37577256", size = 2027513, upload-time = "2026-10-05T17:41:20.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/1d/a3666a7c224f86a881f18d673a8ffdc92fa4c35274fb4361822f8f0dc566/aiohttp-3.14.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0cb3edece6fe6a944eaf97d803b88f606da6bc1600b1894c2969e78ffdc1fc29", size = 2152686, upload-time = "2026-10-05T17:41:23.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/7e/f4204b8c4959c46f6367c182afbbacef08a1b53874281912c52fbd80151c/aiohttp-3.14.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21ce1223b461bcc5fb389a7204001578f1c0b85c2da60a84d915ebb021294245", size = 2006431, upload-time = "2026-10-05T17:41:25.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/43/091e69d42967cbea833ceb355c5457f8f70748fe08b88c9d91491b3fafb8/aiohttp-3.14.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35d5432f0dc1c8b71307e9112461b52f336da225e344283eec4779222c0a1dff", size = 1789101, upload-time = "2026-10-05T17:41:28.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/7b/b57cbe533c01098c5d30471f057e7c3c19f281122f756acc9c716b636e2c/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6ec364493057c118d08292f7cd60865965c209167d8657b45643a26aef77495", size = 1974472, upload-time = "2026-10-05T17:41:30.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/44/79acce03611450e4ed8594f96f1cea40d70bc84fb4e918f94d7d9454a4af/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:70492983b7aed9e61bda82fe1ce80bcba9199610886443133783a288638923c0", size = 1956340, upload-time = "2026-10-05T17:41:33.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/bb/36e6cbd6720e3377a02f8709aec6fe80de729ef41e78ba01f13d50363c73/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a77776673331577147e9d25cd7833a72e8891b47556336cff06ba1569e488b99", size = 1965771, upload-time = "2026-10-05T17:41:35.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/0f/879735ac70ce5e6852916eed51c706a517b205150a8d384f9fa60933a3fc/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:73799000dd6fd5247094fd8e6d3e1eb871e41aab5cc35dff9d9f654cd230bc24", size = 1780297, upload-time = "2026-10-05T17:41:38.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/5d/7c030ca5b6baf3b02eccd11afc3d58f0b35e407031d5cc78cae9c24921c0/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:27b370fb532e707e8c96d04fa0c96301fb7dba2d50aaca57f6c456cdfffec5bd", size = 2001374, upload-time = "2026-10-05T17:41:40.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/1e/ddacbb8141d727034c51dd192d17749521d5b30b03f84aa034325396089e/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:884e0d1c707b6217fee3080b9b4029eee7551af788ee761010df19be158414b7", size = 1968531, upload-time = "2026-10-05T17:41:43.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/08/2214f823701eebc3e57db141faeae76df7bfd415f254cc7c1692258f228d/aiohttp-3.14.4-cp314-cp314t-win32.whl", hash = "sha256:f2e3d34edb151d3e27f93a8338e2da33e2c7261cd4c879ce5f5fe280cc190ea5", size = 504126, upload-time = "2026-10-05T17:41:46.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/a1/90e9b6fd23310b4e82d0c0a37b5ecc2944053350473c57533c1da9647235/aiohttp-3.14.4-cp314-cp314t-win_amd64.whl", hash = "sha256:88cf889e51092537fe44adfbff96c2a22d6479d6f92b97b3d413fbfd0a4decf4", size = 531494, upload-time = "2026-10-05T17:41:48.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/ab/5b501472c92b9b1dc53742591d6210dccb262962f4721299300d3a9f664b/aiohttp-3.14.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ca450c6d42fda7c0bfaf0e200f32f77c5f2c960a33be1e14d197c84588b7f5f6", size = 510544, upload-time = "2026-10-05T17:41:51.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/27/5e1f8446545be98e9416af59c396b1ed1f9f3294afb0a40ee3a709889273/aiohttp-3.14.4-py3-none-any.whl", hash = "sha256:5c6758ba62aea282c537179cfc8474a90f2b5f7b089cc5ff66d8920d86a9bfdd", size = 279487, upload-time = "2026-10-05T17:43:44.905Z" },
|
||||
{ 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]]
|
||||
|
|
@ -1146,19 +1146,22 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "claude-agent-sdk"
|
||||
version = "0.1.44"
|
||||
version = "0.2.165"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mcp" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/40/5661e10daf69ee5c864f82a1888cc33c9378b2d7f7d11db3c2360aef3a30/claude_agent_sdk-0.1.44.tar.gz", hash = "sha256:8629436e7af367a1cbc81aa2a58a93aa68b8b2e4e14b0c5be5ac3627bd462c1b", size = 62439, upload-time = "2026-02-26T01:17:28.118Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/45/10f00a408b480926b5e340e3507562ede1e30d4ce5b73eea9ccc3468a9e6/claude_agent_sdk-0.2.165.tar.gz", hash = "sha256:1bfa8e7a6bb36e82de9a12324c9bcfa6eebd4f96ee5890f0c656d50445240ff8", size = 365169, upload-time = "2026-10-08T18:18:49.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/1a/dcde83a6477bfdf8c5510fd84006cca763296e6bc5576e90cd89b97ec034/claude_agent_sdk-0.1.44-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1dd976ad3efb673aefd5037dc75ee7926fb5033c4b9ab7382897ab647fed74e6", size = 55828889, upload-time = "2026-02-26T01:17:15.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/33/3b161256956968e18c81e2b2650fed7d2a1144d51042ed6317848643e5d7/claude_agent_sdk-0.1.44-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:d35b38ca40fa28f50fa88705599a298ab30c121c56b53655025eeceb463ac399", size = 70795212, upload-time = "2026-02-26T01:17:18.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/cb/67af9796dad77a94dfe851138f5ffc9e2e0a14407ba55fea07462c1cc8e5/claude_agent_sdk-0.1.44-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:853c15501f71a913a6cc6b40dc0b24b9505166cad164206b8eab229889e670b8", size = 71424685, upload-time = "2026-02-26T01:17:22.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/cd/2d3806c791250a76de2c1be863fc01d420729ad61496253e3d3033464c72/claude_agent_sdk-0.1.44-py3-none-win_amd64.whl", hash = "sha256:597e2fcad372086f93e4f6a380d3088ec4dd9b9efce309c5281b52a256fd5d25", size = 73493771, upload-time = "2026-02-26T01:17:25.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/a7/a7226ae5e7fa3f225d99a0acf12ee354c4af9c953661f66bbcd424e48853/claude_agent_sdk-0.2.165-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f7017bb59eaf77b9a7c7ce3b9ed2c8a397b8201c8630f011bb1b955290f06e4", size = 97973182, upload-time = "2026-10-08T18:18:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/9d/1eaa82f8dbafa63a3fb26aabc088be307c4977438269cc6cb10056a32e99/claude_agent_sdk-0.2.165-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:f4b5c6f536062e3af72357b1235f05ad4513230a32a3c639341a91a607d2df1e", size = 102479136, upload-time = "2026-10-08T18:19:01.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/4a/93fe172811bcd7704c4c4776d5eed1bb1881ba440d86e0f8e5cbbec0dca9/claude_agent_sdk-0.2.165-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:46a47e1e1075a8f2b7976f6bd5aa06c5610cb322c47cdc5b728fb25d02691164", size = 107447420, upload-time = "2026-10-08T18:19:09.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/81/a0d3ff04ae7045218566b36d1c78729c3fc7550877e154697f263ac5122e/claude_agent_sdk-0.2.165-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:9dbee4bfc69f0bb27ee455afdc19d78f958540a99217d2e28831fb9b7d496f08", size = 107800988, upload-time = "2026-10-08T18:19:16.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/d8/1685e8fc14bae5b9cbcfd6f4f6ca8b7ae8fd497a8bbf9c108978dd98620b/claude_agent_sdk-0.2.165-py3-none-win_amd64.whl", hash = "sha256:cf41dc1b1019bc7321b695d621fc94e6d8982c095cfb7d433d5c293e356a2404", size = 110401968, upload-time = "2026-10-08T18:19:24.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4871,7 +4874,7 @@ ci = [
|
|||
{ name = "assemblyai", specifier = "==0.52.4" },
|
||||
{ name = "beautifulsoup4", specifier = "==4.14.3" },
|
||||
{ name = "blockbuster", specifier = "==1.5.26" },
|
||||
{ name = "claude-agent-sdk", specifier = "==0.1.44" },
|
||||
{ name = "claude-agent-sdk", specifier = "==0.2.165" },
|
||||
{ name = "detect-secrets", specifier = "==1.5.0" },
|
||||
{ name = "google-cloud-speech", specifier = "==2.40.0" },
|
||||
{ name = "google-generativeai", specifier = "==0.8.6" },
|
||||
|
|
@ -4961,7 +4964,7 @@ proxy-dev = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.73"
|
||||
version = "0.1.73.post1"
|
||||
source = { editable = "enterprise" }
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue