mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge pull request #31259 from BerriAI/litellm_backport_1_89_x_bp-189x-0624
chore(release): backport #30787, #30788, #31035, #31036, #31122, #31133 to stable/1.89.x (litellm-enterprise 0.1.42.post2)
This commit is contained in:
commit
7f7796906b
50 changed files with 4049 additions and 1039 deletions
|
|
@ -1,8 +1,8 @@
|
|||
# Base image for building
|
||||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9
|
||||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
|
||||
# Runtime image
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
|
||||
|
||||
FROM $UV_IMAGE AS uvbin
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9
|
||||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
|
||||
|
||||
FROM $UV_IMAGE AS uvbin
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Base image for building
|
||||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:3258be472764337fd13095bcbb3182da170243b5819fd67ad4c0754590588b31
|
||||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
|
||||
# Runtime image
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:3258be472764337fd13095bcbb3182da170243b5819fd67ad4c0754590588b31
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
|
||||
|
||||
FROM $UV_IMAGE AS uvbin
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Base images
|
||||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:3258be472764337fd13095bcbb3182da170243b5819fd67ad4c0754590588b31
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:3258be472764337fd13095bcbb3182da170243b5819fd67ad4c0754590588b31
|
||||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
ARG PROXY_EXTRAS_SOURCE=published
|
||||
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ from litellm import Router, verbose_logger
|
|||
from litellm._uuid import uuid
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
extract_file_metadata,
|
||||
)
|
||||
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints
|
||||
from litellm.llms.base_llm.managed_resources.isolation import (
|
||||
build_list_page,
|
||||
|
|
@ -981,9 +983,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
target_model_names_list: List[str],
|
||||
) -> OpenAIFileObject:
|
||||
## GET THE FILE TYPE FROM THE CREATE FILE REQUEST
|
||||
file_data = extract_file_data(create_file_request["file"])
|
||||
|
||||
file_type = file_data["content_type"]
|
||||
_, file_type = extract_file_metadata(create_file_request["file"])
|
||||
|
||||
output_file_id = file_objects[0].id
|
||||
model_id = file_objects[0]._hidden_params.get("model_id")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.42"
|
||||
version = "0.1.42.post2"
|
||||
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.42"
|
||||
version = "0.1.42.post2"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9
|
||||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
|
||||
|
||||
FROM $UV_IMAGE AS uvbin
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import json
|
||||
from typing import Any, List, Literal, Optional, Tuple
|
||||
from typing import Any, Iterator, List, Literal, Optional, Tuple
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -314,6 +314,70 @@ def _get_file_content_as_dictionary(file_content: bytes) -> List[dict]:
|
|||
raise e
|
||||
|
||||
|
||||
def _iter_batch_input_lines(file_content: bytes) -> Iterator[bytes]:
|
||||
"""
|
||||
Yield non-empty JSONL lines (unparsed) one at a time, so a caller can parse
|
||||
each row in its own try/except and a single malformed line cannot abort the
|
||||
whole pass. Peak memory stays bounded for large batch files.
|
||||
"""
|
||||
start, length, newline = 0, len(file_content), ord("\n")
|
||||
while start < length:
|
||||
idx = file_content.find(newline, start)
|
||||
if idx == -1:
|
||||
chunk, start = file_content[start:], length
|
||||
else:
|
||||
chunk, start = file_content[start:idx], idx + 1
|
||||
line = chunk.strip()
|
||||
if line:
|
||||
yield line
|
||||
|
||||
|
||||
def _iter_batch_input_entries(file_content: bytes) -> Iterator[dict]:
|
||||
"""
|
||||
Yield parsed batch input JSONL entries one at a time without materializing the
|
||||
whole file as a list, so peak memory stays bounded. Raises on a malformed line;
|
||||
callers that must survive bad rows should iterate ``_iter_batch_input_lines``
|
||||
and parse per-row instead.
|
||||
"""
|
||||
for line in _iter_batch_input_lines(file_content):
|
||||
yield json.loads(line)
|
||||
|
||||
|
||||
# A batch request's input tokens scale roughly with its serialized size, so this
|
||||
# is a conservative per-row fallback when the token counter cannot measure a row.
|
||||
_BATCH_TOKEN_ESTIMATE_BYTES_PER_TOKEN = 4
|
||||
|
||||
|
||||
def _estimate_batch_entry_tokens(raw_line: bytes) -> int:
|
||||
"""Conservative token estimate for a batch row the token counter cannot measure
|
||||
(or that cannot be parsed). Keeps the batch token total non-zero so a crafted
|
||||
row cannot evade the TPM limit, without hard-rejecting a legitimate batch."""
|
||||
return max(1, len(raw_line) // _BATCH_TOKEN_ESTIMATE_BYTES_PER_TOKEN)
|
||||
|
||||
|
||||
def _count_entry_tokens(
|
||||
entry: dict,
|
||||
model_name: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Token-count a single batch input entry's body (chat / text / embedding)."""
|
||||
body = entry.get("body", {}) or {}
|
||||
model = body.get("model", model_name or "")
|
||||
|
||||
messages = body.get("messages")
|
||||
if messages:
|
||||
return token_counter(model=model, messages=messages)
|
||||
|
||||
prompt = body.get("prompt")
|
||||
if prompt:
|
||||
return _count_prompt_or_input_tokens(model=model, value=prompt)
|
||||
|
||||
input_data = body.get("input")
|
||||
if input_data:
|
||||
return _count_prompt_or_input_tokens(model=model, value=input_data)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _get_batch_job_cost_from_file_content(
|
||||
file_content_dictionary: List[dict],
|
||||
custom_llm_provider: Literal[
|
||||
|
|
@ -396,70 +460,6 @@ def _get_batch_job_total_usage_from_file_content(
|
|||
)
|
||||
|
||||
|
||||
def _get_models_from_batch_input_file_content(
|
||||
file_content_dictionary: List[dict],
|
||||
) -> List[str]:
|
||||
"""Extract the distinct ``body.model`` values from a batch *input* file.
|
||||
|
||||
Used by the proxy's batch pre-call hook to enforce that the caller is
|
||||
authorized for every model named inside the JSONL — not just the one
|
||||
on the outer request — so the proxy's per-key model allowlist isn't
|
||||
bypassed by smuggling expensive models into the batch file.
|
||||
"""
|
||||
models: List[str] = []
|
||||
seen: set = set()
|
||||
for _item in file_content_dictionary:
|
||||
body = _item.get("body") or {}
|
||||
model = body.get("model")
|
||||
if model and model not in seen:
|
||||
seen.add(model)
|
||||
models.append(model)
|
||||
return models
|
||||
|
||||
|
||||
def _get_batch_job_input_file_usage(
|
||||
file_content_dictionary: List[dict],
|
||||
custom_llm_provider: Literal["openai", "azure", "vertex_ai"] = "openai",
|
||||
model_name: Optional[str] = None,
|
||||
) -> Usage:
|
||||
"""
|
||||
Count the number of tokens in the input file
|
||||
|
||||
Used for batch rate limiting to count the number of tokens in the input file
|
||||
"""
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
|
||||
for _item in file_content_dictionary:
|
||||
body = _item.get("body", {})
|
||||
model = body.get("model", model_name or "")
|
||||
|
||||
# Chat completion payloads.
|
||||
messages = body.get("messages")
|
||||
if messages:
|
||||
prompt_tokens += token_counter(model=model, messages=messages)
|
||||
continue
|
||||
|
||||
# Text completion payloads (`prompt`).
|
||||
prompt = body.get("prompt")
|
||||
if prompt:
|
||||
prompt_tokens += _count_prompt_or_input_tokens(model=model, value=prompt)
|
||||
continue
|
||||
|
||||
# Embedding payloads (`input`).
|
||||
input_data = body.get("input")
|
||||
if input_data:
|
||||
prompt_tokens += _count_prompt_or_input_tokens(
|
||||
model=model, value=input_data
|
||||
)
|
||||
|
||||
return Usage(
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
)
|
||||
|
||||
|
||||
def _count_prompt_or_input_tokens(model: str, value: Any) -> int:
|
||||
"""Token-count a ``prompt`` / ``input`` field that the OpenAI batch
|
||||
schema allows in four shapes:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,22 @@ from typing import Optional
|
|||
from litellm.types.llms.openai import CreateFileRequest
|
||||
from litellm.types.utils import ExtractedFileData
|
||||
|
||||
# MIME types a .jsonl batch upload is plausibly labeled with. Clients are
|
||||
# inconsistent (text/plain, application/json, octet-stream, ndjson, ...), so a
|
||||
# batch file must not silently bypass the streaming path just because of its
|
||||
# declared type. ``purpose == "batch"`` is the authoritative signal; non-JSONL
|
||||
# content still fails loudly when the rows are parsed.
|
||||
_BATCH_JSONL_CONTENT_TYPES = frozenset(
|
||||
{
|
||||
"application/jsonl",
|
||||
"application/json",
|
||||
"application/octet-stream",
|
||||
"application/x-ndjson",
|
||||
"application/x-jsonlines",
|
||||
"text/plain",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FilesAPIUtils:
|
||||
"""
|
||||
|
|
@ -24,9 +40,24 @@ class FilesAPIUtils:
|
|||
and extracted_file_data.get("content") is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_batch_jsonl_request(
|
||||
create_file_data: CreateFileRequest, content_type: Optional[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Batch-jsonl check from metadata only, so the body can stay a streamable
|
||||
Path/handle instead of being read into memory.
|
||||
"""
|
||||
return (
|
||||
create_file_data.get("purpose") == "batch"
|
||||
and FilesAPIUtils.valid_content_type(content_type)
|
||||
and create_file_data.get("file") is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def valid_content_type(content_type: Optional[str]) -> bool:
|
||||
"""
|
||||
Check if the content type is valid
|
||||
Whether the upload's MIME type is one a batch JSONL file is plausibly
|
||||
sent as (see ``_BATCH_JSONL_CONTENT_TYPES``).
|
||||
"""
|
||||
return content_type in set(["application/jsonl", "application/octet-stream"])
|
||||
return content_type in _BATCH_JSONL_CONTENT_TYPES
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ _OPTIONAL_KWARGS_KEYS = frozenset(
|
|||
"azure_password",
|
||||
"azure_scope",
|
||||
"timeout",
|
||||
"gcs_bucket_name",
|
||||
"bucket_name",
|
||||
"vertex_credentials",
|
||||
"vertex_project",
|
||||
|
|
|
|||
|
|
@ -2970,7 +2970,12 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
)
|
||||
self.model_call_details["end_time"] = end_time
|
||||
self.model_call_details.setdefault("original_response", None)
|
||||
self.model_call_details["response_cost"] = 0
|
||||
# A stream interrupted mid-flight still billed the provider for the
|
||||
# chunks already delivered; the router stashes that recovered usage as
|
||||
# ``combined_usage_object`` and pre-computes its cost, so preserve it
|
||||
# here instead of zeroing the spend on an otherwise-failed request.
|
||||
if self.model_call_details.get("combined_usage_object") is None:
|
||||
self.model_call_details["response_cost"] = 0
|
||||
|
||||
if hasattr(exception, "headers") and isinstance(exception.headers, dict):
|
||||
self.model_call_details.setdefault("litellm_params", {})
|
||||
|
|
|
|||
|
|
@ -757,6 +757,46 @@ def update_responses_tools_with_model_file_ids(
|
|||
return updated_tools
|
||||
|
||||
|
||||
def extract_file_metadata(file_data: FileTypes) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Resolve (filename, content_type) without reading the file body.
|
||||
|
||||
Mirrors extract_file_data's metadata resolution but never calls .read(), so
|
||||
it stays O(1) on large uploads. Use this when only metadata is needed (batch
|
||||
detection, GCS object naming) and the body must remain a streamable Path/handle.
|
||||
"""
|
||||
filename: Optional[str] = None
|
||||
content_type: Optional[str] = None
|
||||
file_content: Any = None
|
||||
|
||||
if isinstance(file_data, tuple):
|
||||
if len(file_data) == 2:
|
||||
filename, file_content = file_data
|
||||
elif len(file_data) == 3:
|
||||
filename, file_content, content_type = file_data
|
||||
elif len(file_data) == 4:
|
||||
filename, file_content, content_type, _ = file_data
|
||||
elif isinstance(file_data, InMemoryFile):
|
||||
filename = file_data.name
|
||||
content_type = file_data.content_type
|
||||
else:
|
||||
file_content = file_data
|
||||
|
||||
if filename is None:
|
||||
if isinstance(file_content, PathLike):
|
||||
filename = Path(file_content).name
|
||||
elif isinstance(file_content, io.IOBase):
|
||||
name_attr = getattr(file_content, "name", None)
|
||||
if isinstance(name_attr, str):
|
||||
filename = Path(name_attr).name
|
||||
|
||||
if not content_type:
|
||||
guessed = mimetypes.guess_type(filename)[0] if filename else None
|
||||
content_type = guessed or "application/octet-stream"
|
||||
|
||||
return filename, content_type
|
||||
|
||||
|
||||
def extract_file_data(file_data: FileTypes) -> ExtractedFileData:
|
||||
"""
|
||||
Extracts and processes file data from various input formats.
|
||||
|
|
|
|||
|
|
@ -1974,11 +1974,29 @@ class CustomStreamWrapper:
|
|||
|
||||
except StopIteration:
|
||||
if self.sent_last_chunk is True:
|
||||
complete_streaming_response = litellm.stream_chunk_builder(
|
||||
chunks=self.chunks,
|
||||
messages=self.messages,
|
||||
logging_obj=self.logging_obj,
|
||||
)
|
||||
try:
|
||||
complete_streaming_response = litellm.stream_chunk_builder(
|
||||
chunks=self.chunks,
|
||||
messages=self.messages,
|
||||
logging_obj=self.logging_obj,
|
||||
)
|
||||
except Exception as e:
|
||||
# stream_chunk_builder can re-raise (as APIError) on large agentic
|
||||
# streams. The raise originates inside this except-StopIteration block,
|
||||
# so the sibling `except Exception` below does not catch it; it would
|
||||
# escape __next__ and drop the request from SpendLogs. Recover
|
||||
# best-effort usage from the raw chunks so cost is still tracked
|
||||
verbose_logger.warning(
|
||||
"stream_chunk_builder raised at end-of-stream (%s); logging "
|
||||
"best-effort usage from chunks.",
|
||||
str(e),
|
||||
)
|
||||
try:
|
||||
complete_streaming_response = self.model_response_creator(
|
||||
chunk={"usage": calculate_total_usage(chunks=self.chunks)}
|
||||
)
|
||||
except Exception:
|
||||
complete_streaming_response = None
|
||||
|
||||
response = self.model_response_creator()
|
||||
if complete_streaming_response is not None:
|
||||
|
|
@ -2203,11 +2221,27 @@ class CustomStreamWrapper:
|
|||
except (StopAsyncIteration, StopIteration):
|
||||
if self.sent_last_chunk is True:
|
||||
# log the final chunk with accurate streaming values
|
||||
complete_streaming_response = litellm.stream_chunk_builder(
|
||||
chunks=self.chunks,
|
||||
messages=self.messages,
|
||||
logging_obj=self.logging_obj,
|
||||
)
|
||||
try:
|
||||
complete_streaming_response = litellm.stream_chunk_builder(
|
||||
chunks=self.chunks,
|
||||
messages=self.messages,
|
||||
logging_obj=self.logging_obj,
|
||||
)
|
||||
except Exception as e:
|
||||
# see sync __next__: a raise from stream_chunk_builder inside this
|
||||
# except handler escapes __anext__ and drops the request from SpendLogs.
|
||||
# Recover best-effort usage from the raw chunks so cost is still tracked
|
||||
verbose_logger.warning(
|
||||
"stream_chunk_builder raised at end-of-stream (%s); logging "
|
||||
"best-effort usage from chunks.",
|
||||
str(e),
|
||||
)
|
||||
try:
|
||||
complete_streaming_response = self.model_response_creator(
|
||||
chunk={"usage": calculate_total_usage(chunks=self.chunks)}
|
||||
)
|
||||
except Exception:
|
||||
complete_streaming_response = None
|
||||
|
||||
response = self.model_response_creator()
|
||||
if complete_streaming_response is not None:
|
||||
|
|
@ -2284,6 +2318,7 @@ class CustomStreamWrapper:
|
|||
litellm.request_timeout
|
||||
)
|
||||
if self.logging_obj is not None:
|
||||
self._record_partial_usage_for_failure()
|
||||
## LOGGING
|
||||
threading.Thread(
|
||||
target=self.logging_obj.failure_handler,
|
||||
|
|
@ -2297,6 +2332,7 @@ class CustomStreamWrapper:
|
|||
except Exception as e:
|
||||
traceback_exception = traceback.format_exc()
|
||||
if self.logging_obj is not None:
|
||||
self._record_partial_usage_for_failure()
|
||||
## LOGGING
|
||||
threading.Thread(
|
||||
target=self.logging_obj.failure_handler,
|
||||
|
|
@ -2308,6 +2344,33 @@ class CustomStreamWrapper:
|
|||
)
|
||||
self._handle_stream_fallback_error(e)
|
||||
|
||||
def _record_partial_usage_for_failure(self) -> None:
|
||||
"""
|
||||
A stream that breaks mid-flight still billed the provider for the chunks
|
||||
already delivered. Recover that partial usage from the chunks seen so
|
||||
far and stash it, with its cost, on the logging object so the failure
|
||||
handler records the real partial spend instead of zero. A request that
|
||||
later recovers via a router fallback overwrites this with the combined
|
||||
success log on the same request id, so this never double counts.
|
||||
"""
|
||||
if self.logging_obj is None or not self.chunks:
|
||||
return
|
||||
try:
|
||||
partial_response = litellm.stream_chunk_builder(chunks=self.chunks)
|
||||
usage = cast(Optional[Usage], getattr(partial_response, "usage", None))
|
||||
if usage is None:
|
||||
return
|
||||
self.logging_obj.model_call_details["combined_usage_object"] = usage
|
||||
self.logging_obj.model_call_details["response_cost"] = (
|
||||
self.logging_obj._response_cost_calculator(result=partial_response)
|
||||
or 0.0
|
||||
)
|
||||
except Exception as recover_error:
|
||||
verbose_logger.debug(
|
||||
"could not recover partial usage for interrupted stream: %s",
|
||||
recover_error,
|
||||
)
|
||||
|
||||
def _handle_stream_fallback_error(self, e: Exception) -> "NoReturn":
|
||||
"""
|
||||
Common error handling for both __next__ and __anext__.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Union
|
||||
|
||||
import httpx
|
||||
from openai.types.file_deleted import FileDeleted
|
||||
|
|
@ -32,6 +32,22 @@ else:
|
|||
Router = Any
|
||||
|
||||
|
||||
class BaseFileUploadStream(ABC):
|
||||
"""Re-iterable request body that yields an upload's bytes lazily.
|
||||
|
||||
A provider returns one of these (inside the upload config from
|
||||
``transform_create_file_request``) when the upload body can be produced
|
||||
incrementally; the HTTP handler then sends it in bounded chunks instead of
|
||||
buffering the whole payload, which is what exhausts memory on large uploads.
|
||||
|
||||
``iter_bytes`` must return a fresh iterator each call so the body can be
|
||||
replayed if the upload is retried.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def iter_bytes(self) -> Iterator[bytes]: ...
|
||||
|
||||
|
||||
class BaseFilesConfig(BaseConfig):
|
||||
@property
|
||||
@abstractmethod
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Coroutine,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
|
|
@ -14,6 +15,7 @@ from typing import (
|
|||
Union,
|
||||
cast,
|
||||
)
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx # type: ignore
|
||||
from openai.types.file_deleted import FileDeleted
|
||||
|
|
@ -3230,6 +3232,23 @@ class BaseLLMHTTPHandler:
|
|||
data=presigned_request["data"],
|
||||
timeout=timeout,
|
||||
)
|
||||
elif (
|
||||
isinstance(transformed_request, dict)
|
||||
and "resumable_chunked_upload" in transformed_request
|
||||
):
|
||||
try:
|
||||
upload_response = self._resumable_chunked_upload(
|
||||
client=sync_httpx_client,
|
||||
initiate_url=api_base,
|
||||
base_headers=headers,
|
||||
config=cast(Dict[str, Any], transformed_request)[
|
||||
"resumable_chunked_upload"
|
||||
],
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.exception(f"Error creating file: {e}")
|
||||
raise self._handle_error(e=e, provider_config=provider_config)
|
||||
elif isinstance(transformed_request, str) or isinstance(
|
||||
transformed_request, bytes
|
||||
):
|
||||
|
|
@ -3311,7 +3330,15 @@ class BaseLLMHTTPHandler:
|
|||
input="",
|
||||
api_key="",
|
||||
additional_args={
|
||||
"complete_input_dict": transformed_request,
|
||||
# A resumable upload config holds a reference to the (potentially
|
||||
# huge) upload payload; logging deep-copies additional_args, so log
|
||||
# a placeholder instead of re-materializing the payload.
|
||||
"complete_input_dict": (
|
||||
"<resumable chunked upload>"
|
||||
if isinstance(transformed_request, dict)
|
||||
and "resumable_chunked_upload" in transformed_request
|
||||
else transformed_request
|
||||
),
|
||||
"api_base": api_base,
|
||||
"headers": headers,
|
||||
},
|
||||
|
|
@ -3388,6 +3415,23 @@ class BaseLLMHTTPHandler:
|
|||
data=presigned_request["data"],
|
||||
timeout=timeout,
|
||||
)
|
||||
elif (
|
||||
isinstance(transformed_request, dict)
|
||||
and "resumable_chunked_upload" in transformed_request
|
||||
):
|
||||
try:
|
||||
upload_response = await self._aresumable_chunked_upload(
|
||||
client=async_httpx_client,
|
||||
initiate_url=api_base,
|
||||
base_headers=headers,
|
||||
config=cast(Dict[str, Any], transformed_request)[
|
||||
"resumable_chunked_upload"
|
||||
],
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.exception(f"Error creating file: {e}")
|
||||
raise self._handle_error(e=e, provider_config=provider_config)
|
||||
elif isinstance(transformed_request, str) or isinstance(
|
||||
transformed_request, bytes
|
||||
):
|
||||
|
|
@ -3431,6 +3475,224 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
# 8 MiB; a 256 KiB multiple, which GCS requires for every non-final chunk.
|
||||
_RESUMABLE_CHUNK_SIZE = 8 * 1024 * 1024
|
||||
|
||||
@staticmethod
|
||||
def _iter_resumable_chunks(
|
||||
byte_iter: Iterator[bytes], chunk_size: int
|
||||
) -> Iterator[bytes]:
|
||||
"""Regroup a byte stream into ``chunk_size`` pieces, yielding a final
|
||||
partial piece only when it is non-empty. Every full piece is exactly
|
||||
``chunk_size`` bytes (kept a 256 KiB multiple for GCS) and never more than
|
||||
one chunk is buffered. An exactly chunk-aligned stream yields only full
|
||||
chunks, so the upload finalizes on its last data chunk instead of making
|
||||
an extra empty request; a 0-byte stream yields nothing and the caller
|
||||
finalizes with a single empty request.
|
||||
"""
|
||||
buf = bytearray()
|
||||
for piece in byte_iter:
|
||||
buf.extend(piece)
|
||||
while len(buf) >= chunk_size:
|
||||
yield bytes(buf[:chunk_size])
|
||||
del buf[:chunk_size]
|
||||
if buf:
|
||||
yield bytes(buf)
|
||||
|
||||
@staticmethod
|
||||
def _resumable_content_range(offset: int, data_len: int, is_final: bool) -> str:
|
||||
if not is_final:
|
||||
return f"bytes {offset}-{offset + data_len - 1}/*"
|
||||
total = offset + data_len
|
||||
if data_len == 0:
|
||||
return f"bytes */{total}"
|
||||
return f"bytes {offset}-{total - 1}/{total}"
|
||||
|
||||
@staticmethod
|
||||
def _resumable_request_kwargs(
|
||||
headers: dict,
|
||||
content: bytes,
|
||||
timeout: Optional[Union[float, httpx.Timeout]],
|
||||
) -> dict:
|
||||
kwargs: Dict[str, Any] = {"headers": headers, "content": content}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
return kwargs
|
||||
|
||||
def _resumable_chunked_upload(
|
||||
self,
|
||||
*,
|
||||
client: HTTPHandler,
|
||||
initiate_url: str,
|
||||
base_headers: dict,
|
||||
config: dict,
|
||||
timeout: Optional[Union[float, httpx.Timeout]],
|
||||
) -> httpx.Response:
|
||||
"""Open a GCS resumable session, then PUT the body in bounded chunks so a
|
||||
large upload is never held in memory in full."""
|
||||
stream = config["body_stream"]
|
||||
chunk_size = config.get("chunk_size", self._RESUMABLE_CHUNK_SIZE)
|
||||
session_url_header = config.get("session_url_header", "location")
|
||||
httpx_client = client.client
|
||||
|
||||
init_headers = {**base_headers, **config.get("initiate_headers", {})}
|
||||
init_req = httpx_client.build_request(
|
||||
"POST",
|
||||
initiate_url,
|
||||
**self._resumable_request_kwargs(init_headers, b"", timeout),
|
||||
)
|
||||
init_resp = httpx_client.send(init_req, follow_redirects=False)
|
||||
init_resp.read()
|
||||
if init_resp.status_code not in (200, 201):
|
||||
init_resp.raise_for_status()
|
||||
session_url = init_resp.headers.get(session_url_header)
|
||||
if not session_url:
|
||||
raise ValueError(
|
||||
f"resumable upload: no session URL in '{session_url_header}' header"
|
||||
)
|
||||
|
||||
offset = 0
|
||||
pending: Optional[bytes] = None
|
||||
for chunk in self._iter_resumable_chunks(stream.iter_bytes(), chunk_size):
|
||||
if pending is not None:
|
||||
self._send_resumable_chunk(
|
||||
httpx_client,
|
||||
session_url,
|
||||
base_headers,
|
||||
pending,
|
||||
offset,
|
||||
is_final=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
offset += len(pending)
|
||||
pending = chunk
|
||||
return self._send_resumable_chunk(
|
||||
httpx_client,
|
||||
session_url,
|
||||
base_headers,
|
||||
pending or b"",
|
||||
offset,
|
||||
is_final=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def _send_resumable_chunk(
|
||||
self,
|
||||
httpx_client: httpx.Client,
|
||||
url: str,
|
||||
base_headers: dict,
|
||||
data: bytes,
|
||||
offset: int,
|
||||
*,
|
||||
is_final: bool,
|
||||
timeout: Optional[Union[float, httpx.Timeout]],
|
||||
) -> httpx.Response:
|
||||
headers = {
|
||||
**base_headers,
|
||||
"Content-Range": self._resumable_content_range(offset, len(data), is_final),
|
||||
}
|
||||
req = httpx_client.build_request(
|
||||
"PUT", url, **self._resumable_request_kwargs(headers, data, timeout)
|
||||
)
|
||||
resp = httpx_client.send(req, follow_redirects=False)
|
||||
resp.read()
|
||||
if resp.status_code not in ((200, 201) if is_final else (308,)):
|
||||
# 4xx/5xx raise here; the ValueError catches an unexpected success
|
||||
# status (e.g. a 200 where the protocol expects a 308 between chunks).
|
||||
resp.raise_for_status()
|
||||
raise ValueError(f"resumable upload: unexpected status {resp.status_code}")
|
||||
return resp
|
||||
|
||||
async def _aresumable_chunked_upload(
|
||||
self,
|
||||
*,
|
||||
client: AsyncHTTPHandler,
|
||||
initiate_url: str,
|
||||
base_headers: dict,
|
||||
config: dict,
|
||||
timeout: Optional[Union[float, httpx.Timeout]],
|
||||
) -> httpx.Response:
|
||||
stream = config["body_stream"]
|
||||
chunk_size = config.get("chunk_size", self._RESUMABLE_CHUNK_SIZE)
|
||||
session_url_header = config.get("session_url_header", "location")
|
||||
httpx_client = client.client
|
||||
|
||||
init_headers = {**base_headers, **config.get("initiate_headers", {})}
|
||||
init_req = httpx_client.build_request(
|
||||
"POST",
|
||||
initiate_url,
|
||||
**self._resumable_request_kwargs(init_headers, b"", timeout),
|
||||
)
|
||||
init_resp = await httpx_client.send(init_req, follow_redirects=False)
|
||||
await init_resp.aread()
|
||||
if init_resp.status_code not in (200, 201):
|
||||
init_resp.raise_for_status()
|
||||
session_url = init_resp.headers.get(session_url_header)
|
||||
if not session_url:
|
||||
raise ValueError(
|
||||
f"resumable upload: no session URL in '{session_url_header}' header"
|
||||
)
|
||||
|
||||
offset = 0
|
||||
pending: Optional[bytes] = None
|
||||
# Producing each chunk runs the synchronous per-row transform for that
|
||||
# chunk's worth of rows. Pull it off the event loop thread so a large
|
||||
# upload does not block other concurrent requests between PUTs.
|
||||
chunk_iter = self._iter_resumable_chunks(stream.iter_bytes(), chunk_size)
|
||||
done = object()
|
||||
while True:
|
||||
chunk = await asyncio.to_thread(next, chunk_iter, done)
|
||||
if chunk is done:
|
||||
break
|
||||
if pending is not None:
|
||||
await self._asend_resumable_chunk(
|
||||
httpx_client,
|
||||
session_url,
|
||||
base_headers,
|
||||
pending,
|
||||
offset,
|
||||
is_final=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
offset += len(pending)
|
||||
pending = chunk
|
||||
return await self._asend_resumable_chunk(
|
||||
httpx_client,
|
||||
session_url,
|
||||
base_headers,
|
||||
pending or b"",
|
||||
offset,
|
||||
is_final=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def _asend_resumable_chunk(
|
||||
self,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
url: str,
|
||||
base_headers: dict,
|
||||
data: bytes,
|
||||
offset: int,
|
||||
*,
|
||||
is_final: bool,
|
||||
timeout: Optional[Union[float, httpx.Timeout]],
|
||||
) -> httpx.Response:
|
||||
headers = {
|
||||
**base_headers,
|
||||
"Content-Range": self._resumable_content_range(offset, len(data), is_final),
|
||||
}
|
||||
req = httpx_client.build_request(
|
||||
"PUT", url, **self._resumable_request_kwargs(headers, data, timeout)
|
||||
)
|
||||
resp = await httpx_client.send(req, follow_redirects=False)
|
||||
await resp.aread()
|
||||
if resp.status_code not in ((200, 201) if is_final else (308,)):
|
||||
# 4xx/5xx raise here; the ValueError catches an unexpected success
|
||||
# status (e.g. a 200 where the protocol expects a 308 between chunks).
|
||||
resp.raise_for_status()
|
||||
raise ValueError(f"resumable upload: unexpected status {resp.status_code}")
|
||||
return resp
|
||||
|
||||
def create_batch(
|
||||
self,
|
||||
create_batch_data: "CreateBatchRequest",
|
||||
|
|
|
|||
|
|
@ -17,17 +17,13 @@ from litellm.litellm_core_utils.cloud_storage_security import (
|
|||
)
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.llms.openai import (
|
||||
CreateFileRequest,
|
||||
FileContentRequest,
|
||||
HttpxBinaryResponseContent,
|
||||
OpenAIFileObject,
|
||||
)
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES
|
||||
|
||||
from .transformation import VertexAIFilesConfig, VertexAIJsonlFilesTransformation
|
||||
|
||||
vertex_ai_files_transformation = VertexAIJsonlFilesTransformation()
|
||||
from .transformation import VertexAIFilesConfig
|
||||
|
||||
|
||||
class VertexAIFilesHandler(GCSBucketBase):
|
||||
|
|
@ -43,82 +39,6 @@ class VertexAIFilesHandler(GCSBucketBase):
|
|||
llm_provider=LlmProviders.VERTEX_AI,
|
||||
)
|
||||
|
||||
async def async_create_file(
|
||||
self,
|
||||
create_file_data: CreateFileRequest,
|
||||
api_base: Optional[str],
|
||||
vertex_credentials: Optional[VERTEX_CREDENTIALS_TYPES],
|
||||
vertex_project: Optional[str],
|
||||
vertex_location: Optional[str],
|
||||
timeout: Union[float, httpx.Timeout],
|
||||
max_retries: Optional[int],
|
||||
) -> OpenAIFileObject:
|
||||
gcs_logging_config: GCSLoggingConfig = await self.get_gcs_logging_config(
|
||||
kwargs={}
|
||||
)
|
||||
headers = await self.construct_request_headers(
|
||||
vertex_instance=gcs_logging_config["vertex_instance"],
|
||||
service_account_json=gcs_logging_config["path_service_account"],
|
||||
)
|
||||
bucket_name = gcs_logging_config["bucket_name"]
|
||||
(
|
||||
logging_payload,
|
||||
object_name,
|
||||
) = vertex_ai_files_transformation.transform_openai_file_content_to_vertex_ai_file_content(
|
||||
openai_file_content=create_file_data.get("file")
|
||||
)
|
||||
gcs_upload_response = await self._log_json_data_on_gcs(
|
||||
headers=headers,
|
||||
bucket_name=bucket_name,
|
||||
object_name=object_name,
|
||||
logging_payload=logging_payload,
|
||||
)
|
||||
|
||||
return vertex_ai_files_transformation.transform_gcs_bucket_response_to_openai_file_object(
|
||||
create_file_data=create_file_data,
|
||||
gcs_upload_response=gcs_upload_response,
|
||||
)
|
||||
|
||||
def create_file(
|
||||
self,
|
||||
_is_async: bool,
|
||||
create_file_data: CreateFileRequest,
|
||||
api_base: Optional[str],
|
||||
vertex_credentials: Optional[VERTEX_CREDENTIALS_TYPES],
|
||||
vertex_project: Optional[str],
|
||||
vertex_location: Optional[str],
|
||||
timeout: Union[float, httpx.Timeout],
|
||||
max_retries: Optional[int],
|
||||
) -> Union[OpenAIFileObject, Coroutine[Any, Any, OpenAIFileObject]]:
|
||||
"""
|
||||
Creates a file on VertexAI GCS Bucket
|
||||
|
||||
Only supported for Async litellm.acreate_file
|
||||
"""
|
||||
|
||||
if _is_async:
|
||||
return self.async_create_file(
|
||||
create_file_data=create_file_data,
|
||||
api_base=api_base,
|
||||
vertex_credentials=vertex_credentials,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
else:
|
||||
return asyncio.run(
|
||||
self.async_create_file(
|
||||
create_file_data=create_file_data,
|
||||
api_base=api_base,
|
||||
vertex_credentials=vertex_credentials,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
)
|
||||
|
||||
def _extract_bucket_and_object_from_file_id(
|
||||
self,
|
||||
file_id: str,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
import base64
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
import httpx
|
||||
from httpx import Headers, Response
|
||||
|
|
@ -22,9 +34,13 @@ from litellm.litellm_core_utils.cloud_storage_security import (
|
|||
validate_managed_cloud_file_id,
|
||||
)
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
extract_file_data,
|
||||
extract_file_metadata,
|
||||
)
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.files.transformation import (
|
||||
BaseFileUploadStream,
|
||||
BaseFilesConfig,
|
||||
LiteLLMLoggingObj,
|
||||
)
|
||||
|
|
@ -44,8 +60,9 @@ from litellm.types.llms.openai import (
|
|||
OpenAIFileObject,
|
||||
PathLike,
|
||||
)
|
||||
from litellm.types.files import ResumableChunkedUploadConfig
|
||||
from litellm.types.llms.vertex_ai import GcsBucketResponse
|
||||
from litellm.types.utils import ExtractedFileData, LlmProviders, ModelResponse
|
||||
from litellm.types.utils import LlmProviders, ModelResponse
|
||||
|
||||
from ..common_utils import VertexAIError
|
||||
from ..vertex_llm_base import VertexBase
|
||||
|
|
@ -137,42 +154,140 @@ def _get_litellm_batch_custom_id_from_labels(labels: Dict[str, Any]) -> str:
|
|||
return str(labels.get("litellm_custom_id", "unknown"))
|
||||
|
||||
|
||||
def _openai_batch_jsonl_entries_to_vertex_wrapped_requests(
|
||||
openai_jsonl_content: List[Dict[str, Any]],
|
||||
def _openai_batch_jsonl_entry_to_vertex_wrapped_request(
|
||||
openai_entry: Dict[str, Any],
|
||||
map_openai_to_vertex_params: Callable[[Dict[str, Any]], Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Transforms OpenAI JSONL batch entries to Vertex AI JSONL lines.
|
||||
Transforms a single OpenAI JSONL batch entry into its Vertex wrapped request.
|
||||
|
||||
jsonl body for vertex is {"request": <request_body>}
|
||||
Example Vertex jsonl
|
||||
{"request":{"contents": [{"role": "user", "parts": [{"text": "What is the relation between the following video and image samples?"}, {"fileData": {"fileUri": "gs://cloud-samples-data/generative-ai/video/animals.mp4", "mimeType": "video/mp4"}}, {"fileData": {"fileUri": "gs://cloud-samples-data/generative-ai/image/cricket.jpeg", "mimeType": "image/jpeg"}}]}]}}
|
||||
{"request":{"contents": [{"role": "user", "parts": [{"text": "Describe what is happening in this video."}, {"fileData": {"fileUri": "gs://cloud-samples-data/generative-ai/video/another_video.mov", "mimeType": "video/mov"}}]}]}}
|
||||
"""
|
||||
openai_request_body = openai_entry.get("body") or {}
|
||||
vertex_request_body = _transform_request_body(
|
||||
messages=openai_request_body.get("messages", []),
|
||||
model=openai_request_body.get("model", ""),
|
||||
optional_params=map_openai_to_vertex_params(openai_request_body),
|
||||
custom_llm_provider="vertex_ai",
|
||||
litellm_params={},
|
||||
cached_content=None,
|
||||
)
|
||||
|
||||
custom_id = openai_entry.get("custom_id")
|
||||
if custom_id is not None:
|
||||
if "labels" not in vertex_request_body:
|
||||
vertex_request_body["labels"] = {}
|
||||
_set_litellm_batch_custom_id_labels(vertex_request_body["labels"], custom_id)
|
||||
|
||||
return {"request": vertex_request_body}
|
||||
|
||||
|
||||
def _iter_stripped_lines(raw_lines: Iterable[Union[str, bytes]]) -> Iterator[str]:
|
||||
"""Decode (when needed), strip, and drop blank lines from an iterable of lines."""
|
||||
for raw in raw_lines:
|
||||
line = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw
|
||||
line = line.strip()
|
||||
if line:
|
||||
yield line
|
||||
|
||||
|
||||
def _iter_openai_jsonl_lines(openai_file_content: FileTypes) -> Iterator[str]:
|
||||
"""
|
||||
Yield non-empty JSONL lines one at a time without materializing the whole
|
||||
payload, so peak memory stays bounded regardless of payload size. Mirrors
|
||||
``str.splitlines()`` + ``line.strip()`` for ``\\n`` / ``\\r\\n`` delimited
|
||||
JSONL.
|
||||
"""
|
||||
content: Any = openai_file_content
|
||||
if isinstance(content, tuple):
|
||||
content = content[1]
|
||||
|
||||
if isinstance(content, (bytes, bytearray)):
|
||||
# Scan for newlines in place so a large in-memory payload is not copied
|
||||
# into a BytesIO just to iterate it line by line.
|
||||
newline = ord("\n")
|
||||
start, length = 0, len(content)
|
||||
while start < length:
|
||||
idx = content.find(newline, start)
|
||||
if idx == -1:
|
||||
chunk, start = content[start:], length
|
||||
else:
|
||||
chunk, start = content[start:idx], idx + 1
|
||||
line = chunk.decode("utf-8").strip()
|
||||
if line:
|
||||
yield line
|
||||
return
|
||||
|
||||
if isinstance(content, str):
|
||||
yield from _iter_stripped_lines(io.StringIO(content))
|
||||
return
|
||||
|
||||
if isinstance(content, PathLike):
|
||||
with open(str(content), "rb") as handle:
|
||||
yield from _iter_stripped_lines(handle)
|
||||
return
|
||||
|
||||
if hasattr(content, "read"):
|
||||
# The handle is read twice per upload (first-row probe for the GCS
|
||||
# object name, then the body stream), so it must rewind to 0. A
|
||||
# non-seekable handle would silently resume mid-stream and drop the
|
||||
# already-consumed first row, so reject it loudly instead.
|
||||
seek = getattr(content, "seek", None)
|
||||
if seek is None:
|
||||
raise ValueError(
|
||||
"Batch upload file handle must be seekable; got a non-seekable "
|
||||
"stream. Pass bytes, a path, or a seekable handle."
|
||||
)
|
||||
try:
|
||||
seek(0)
|
||||
except (OSError, ValueError) as e:
|
||||
raise ValueError(
|
||||
"Batch upload file handle must be seekable so it can be re-read "
|
||||
"for the GCS object name and the upload body."
|
||||
) from e
|
||||
yield from _iter_stripped_lines(content)
|
||||
return
|
||||
|
||||
raise ValueError("Unsupported file content type")
|
||||
|
||||
|
||||
def _iter_openai_jsonl_entries(
|
||||
openai_file_content: FileTypes,
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
for line in _iter_openai_jsonl_lines(openai_file_content):
|
||||
yield json.loads(line)
|
||||
|
||||
|
||||
class _OpenAIToVertexBatchUploadStream(BaseFileUploadStream):
|
||||
"""Streams an OpenAI batch JSONL upload as Vertex-wrapped JSONL one row at a
|
||||
time, so the transformed payload is never held in full.
|
||||
|
||||
The transform runs lazily as the HTTP client pulls each chunk, which keeps
|
||||
peak memory at one row regardless of how large the batch file is.
|
||||
"""
|
||||
|
||||
vertex_jsonl_content = []
|
||||
for _openai_jsonl_content in openai_jsonl_content:
|
||||
openai_request_body = _openai_jsonl_content.get("body") or {}
|
||||
vertex_request_body = _transform_request_body(
|
||||
messages=openai_request_body.get("messages", []),
|
||||
model=openai_request_body.get("model", ""),
|
||||
optional_params=map_openai_to_vertex_params(openai_request_body),
|
||||
custom_llm_provider="vertex_ai",
|
||||
litellm_params={},
|
||||
cached_content=None,
|
||||
)
|
||||
def __init__(
|
||||
self,
|
||||
openai_file_content: FileTypes,
|
||||
map_openai_to_vertex_params: Callable[[Dict[str, Any]], Dict[str, Any]],
|
||||
) -> None:
|
||||
self._openai_file_content = openai_file_content
|
||||
self._map_openai_to_vertex_params = map_openai_to_vertex_params
|
||||
|
||||
# Add custom_id as a label for correlation in batch outputs
|
||||
custom_id = _openai_jsonl_content.get("custom_id")
|
||||
if custom_id is not None:
|
||||
if "labels" not in vertex_request_body:
|
||||
vertex_request_body["labels"] = {}
|
||||
_set_litellm_batch_custom_id_labels(
|
||||
vertex_request_body["labels"], custom_id
|
||||
def _iter_vertex_jsonl_chunks(self) -> Iterator[bytes]:
|
||||
first = True
|
||||
for entry in _iter_openai_jsonl_entries(self._openai_file_content):
|
||||
wrapped = _openai_batch_jsonl_entry_to_vertex_wrapped_request(
|
||||
entry, self._map_openai_to_vertex_params
|
||||
)
|
||||
prefix = b"" if first else b"\n"
|
||||
first = False
|
||||
yield prefix + json.dumps(wrapped).encode("utf-8")
|
||||
|
||||
vertex_jsonl_content.append({"request": vertex_request_body})
|
||||
return vertex_jsonl_content
|
||||
def iter_bytes(self) -> Iterator[bytes]:
|
||||
return self._iter_vertex_jsonl_chunks()
|
||||
|
||||
|
||||
class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
||||
|
|
@ -181,7 +296,6 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
|||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.jsonl_transformation = VertexAIJsonlFilesTransformation()
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
|
|
@ -208,43 +322,6 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
|||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
def _get_content_from_openai_file(self, openai_file_content: FileTypes) -> str:
|
||||
"""
|
||||
Helper to extract content from various OpenAI file types and return as string.
|
||||
|
||||
Handles:
|
||||
- Direct content (str, bytes, IO[bytes])
|
||||
- Tuple formats: (filename, content, [content_type], [headers])
|
||||
- PathLike objects
|
||||
"""
|
||||
content: Union[str, bytes] = b""
|
||||
# Extract file content from tuple if necessary
|
||||
if isinstance(openai_file_content, tuple):
|
||||
# Take the second element which is always the file content
|
||||
file_content = openai_file_content[1]
|
||||
else:
|
||||
file_content = openai_file_content
|
||||
|
||||
# Handle different file content types
|
||||
if isinstance(file_content, str):
|
||||
# String content can be used directly
|
||||
content = file_content
|
||||
elif isinstance(file_content, bytes):
|
||||
# Bytes content can be decoded
|
||||
content = file_content
|
||||
elif isinstance(file_content, PathLike): # PathLike
|
||||
with open(str(file_content), "rb") as f:
|
||||
content = f.read()
|
||||
elif hasattr(file_content, "read"): # IO[bytes]
|
||||
# File-like objects need to be read
|
||||
content = file_content.read()
|
||||
|
||||
# Ensure content is string
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode("utf-8")
|
||||
|
||||
return content
|
||||
|
||||
def _get_gcs_object_name_from_batch_jsonl(
|
||||
self,
|
||||
openai_jsonl_content: List[Dict[str, Any]],
|
||||
|
|
@ -261,32 +338,21 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
|||
object_name = f"{VERTEX_AI_MANAGED_GCS_PREFIX}{safe_model_path}/{uuid.uuid4()}"
|
||||
return object_name
|
||||
|
||||
def get_object_name(
|
||||
self, extracted_file_data: ExtractedFileData, purpose: str
|
||||
) -> str:
|
||||
def get_object_name(self, file_data: FileTypes, purpose: str) -> str:
|
||||
"""
|
||||
Get the object name for the request
|
||||
Get the object name for the request.
|
||||
|
||||
Reads only the first JSONL entry (streamed) for batch files, so a large
|
||||
upload is never materialized just to derive the GCS object name.
|
||||
"""
|
||||
extracted_file_data_content = extracted_file_data.get("content")
|
||||
|
||||
if extracted_file_data_content is None:
|
||||
raise ValueError("file content is required")
|
||||
|
||||
if purpose == "batch":
|
||||
## 1. If jsonl, check if there's a model name
|
||||
file_content = self._get_content_from_openai_file(
|
||||
extracted_file_data_content
|
||||
)
|
||||
|
||||
# Split into lines and parse each line as JSON
|
||||
openai_jsonl_content = [
|
||||
json.loads(line) for line in file_content.splitlines() if line.strip()
|
||||
]
|
||||
if len(openai_jsonl_content) > 0:
|
||||
return self._get_gcs_object_name_from_batch_jsonl(openai_jsonl_content)
|
||||
## 1. If jsonl, derive the object name from the first entry's model
|
||||
first_entry = next(_iter_openai_jsonl_entries(file_data), None)
|
||||
if first_entry is not None:
|
||||
return self._get_gcs_object_name_from_batch_jsonl([first_entry])
|
||||
|
||||
## 2. If not jsonl, store under a server-generated managed object name
|
||||
filename = extracted_file_data.get("filename")
|
||||
filename, _ = extract_file_metadata(file_data)
|
||||
return build_managed_cloud_object_name(
|
||||
prefix=f"{VERTEX_AI_MANAGED_GCS_PREFIX}uploads/",
|
||||
filename=filename,
|
||||
|
|
@ -294,7 +360,11 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
|||
)
|
||||
|
||||
def _get_configured_bucket_name(self, litellm_params: Dict) -> str:
|
||||
bucket_name = litellm_params.get("bucket_name") or os.getenv("GCS_BUCKET_NAME")
|
||||
bucket_name = (
|
||||
litellm_params.get("gcs_bucket_name")
|
||||
or litellm_params.get("bucket_name")
|
||||
or os.getenv("GCS_BUCKET_NAME")
|
||||
)
|
||||
if not bucket_name:
|
||||
raise ValueError("GCS bucket_name is required")
|
||||
return bucket_name
|
||||
|
|
@ -319,12 +389,21 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
|||
raise ValueError("file is required")
|
||||
if purpose is None:
|
||||
raise ValueError("purpose is required")
|
||||
extracted_file_data = extract_file_data(file_data)
|
||||
object_name = self.get_object_name(extracted_file_data, purpose)
|
||||
_, content_type = extract_file_metadata(file_data)
|
||||
object_name = self.get_object_name(file_data, purpose)
|
||||
if object_prefix:
|
||||
object_name = f"{object_prefix}/{object_name}"
|
||||
encoded_object_name = encode_gcs_object_name_for_url(object_name)
|
||||
endpoint = f"upload/storage/v1/b/{bucket_name}/o?uploadType=media&name={encoded_object_name}"
|
||||
# Batch jsonl is streamed via a resumable session (bounded memory on
|
||||
# large uploads); everything else is a single simple-media upload.
|
||||
upload_type = (
|
||||
"resumable"
|
||||
if FilesAPIUtils.is_batch_jsonl_request(
|
||||
create_file_data=data, content_type=content_type
|
||||
)
|
||||
else "media"
|
||||
)
|
||||
endpoint = f"upload/storage/v1/b/{bucket_name}/o?uploadType={upload_type}&name={encoded_object_name}"
|
||||
api_base = api_base or "https://storage.googleapis.com"
|
||||
if not api_base:
|
||||
raise ValueError("api_base is required")
|
||||
|
|
@ -366,14 +445,6 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
|||
)
|
||||
return vertex_params
|
||||
|
||||
def _transform_openai_jsonl_content_to_vertex_ai_jsonl_content(
|
||||
self, openai_jsonl_content: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
return _openai_batch_jsonl_entries_to_vertex_wrapped_requests(
|
||||
openai_jsonl_content=openai_jsonl_content,
|
||||
map_openai_to_vertex_params=self._map_openai_to_vertex_params,
|
||||
)
|
||||
|
||||
def transform_create_file_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -384,40 +455,34 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
|||
"""
|
||||
2 Cases:
|
||||
1. Handle basic file upload
|
||||
2. Handle batch file upload (.jsonl)
|
||||
2. Handle batch file upload (.jsonl), streamed to a GCS resumable
|
||||
session so large uploads stay memory-bounded.
|
||||
"""
|
||||
file_data = create_file_data.get("file")
|
||||
if file_data is None:
|
||||
raise ValueError("file is required")
|
||||
extracted_file_data = extract_file_data(file_data)
|
||||
extracted_file_data_content = extracted_file_data.get("content")
|
||||
|
||||
if extracted_file_data_content is None:
|
||||
raise ValueError("file content is required")
|
||||
|
||||
if FilesAPIUtils.is_batch_jsonl_file(
|
||||
_, content_type = extract_file_metadata(file_data)
|
||||
if FilesAPIUtils.is_batch_jsonl_request(
|
||||
create_file_data=create_file_data,
|
||||
extracted_file_data=extracted_file_data,
|
||||
content_type=content_type,
|
||||
):
|
||||
## 1. If jsonl, check if there's a model name
|
||||
file_content = self._get_content_from_openai_file(
|
||||
extracted_file_data_content
|
||||
)
|
||||
|
||||
# Split into lines and parse each line as JSON
|
||||
openai_jsonl_content = [
|
||||
json.loads(line) for line in file_content.splitlines() if line.strip()
|
||||
]
|
||||
vertex_jsonl_content = (
|
||||
self._transform_openai_jsonl_content_to_vertex_ai_jsonl_content(
|
||||
openai_jsonl_content
|
||||
return {
|
||||
"resumable_chunked_upload": ResumableChunkedUploadConfig(
|
||||
body_stream=_OpenAIToVertexBatchUploadStream(
|
||||
file_data,
|
||||
self._map_openai_to_vertex_params,
|
||||
),
|
||||
initiate_headers={
|
||||
"X-Upload-Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
)
|
||||
return "\n".join(json.dumps(item) for item in vertex_jsonl_content)
|
||||
elif isinstance(extracted_file_data_content, bytes):
|
||||
}
|
||||
|
||||
extracted_file_data_content = extract_file_data(file_data).get("content")
|
||||
if isinstance(extracted_file_data_content, bytes):
|
||||
return extracted_file_data_content
|
||||
else:
|
||||
raise ValueError("Unsupported file content type")
|
||||
raise ValueError("Unsupported file content type")
|
||||
|
||||
def transform_create_file_response(
|
||||
self,
|
||||
|
|
@ -642,39 +707,38 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
|||
}
|
||||
"""
|
||||
try:
|
||||
# Decode content
|
||||
content_str = content.decode("utf-8")
|
||||
|
||||
# Check if it's JSONL (multiple lines)
|
||||
lines = content_str.strip().split("\n")
|
||||
if not lines:
|
||||
# Read the result file one row at a time. Batch output files can be
|
||||
# as large as the (multi-GB) input, so splitting into a list of rows
|
||||
# and building a second list of transformed rows peaks at several full
|
||||
# copies and OOMs on retrieval.
|
||||
lines = _iter_openai_jsonl_lines(content)
|
||||
try:
|
||||
first_line = next(lines)
|
||||
except StopIteration:
|
||||
return content
|
||||
|
||||
# Try to parse the first line to see if it's Vertex AI batch output
|
||||
first_line = json.loads(lines[0])
|
||||
|
||||
# Check if it has Vertex AI batch output structure with discriminating fields
|
||||
# Must have request, response, and processed_time
|
||||
# Plus either candidates (success) or status (error)
|
||||
has_base_structure = (
|
||||
"response" in first_line
|
||||
and "request" in first_line
|
||||
and "processed_time" in first_line
|
||||
# Identify a Vertex AI batch output from the first row's
|
||||
# discriminating fields. Anything else (e.g. a binary file whose
|
||||
# first line is not valid UTF-8/JSON) raises and falls through to the
|
||||
# passthrough below, leaving the content untouched.
|
||||
first_row = json.loads(first_line)
|
||||
is_vertex_batch_output = (
|
||||
"request" in first_row
|
||||
and "response" in first_row
|
||||
and "processed_time" in first_row
|
||||
and (
|
||||
"candidates" in first_row.get("response", {})
|
||||
or "promptFeedback" in first_row.get("response", {})
|
||||
or bool(first_row.get("status"))
|
||||
)
|
||||
)
|
||||
has_success_or_error = (
|
||||
"candidates" in first_line.get("response", {})
|
||||
or "promptFeedback" in first_line.get("response", {})
|
||||
or bool(first_line.get("status"))
|
||||
)
|
||||
|
||||
if not (has_base_structure and has_success_or_error):
|
||||
# Not a Vertex AI batch output, return as-is
|
||||
if not is_vertex_batch_output:
|
||||
return content
|
||||
|
||||
vertex_gemini_config = VertexGeminiConfig()
|
||||
# Always use a fresh local Logging object for the per-line transformation
|
||||
# so we never mutate the caller's logging_obj (which already went through
|
||||
# pre_call and has its own model/start_time/optional_params set).
|
||||
# Use a fresh Logging object for the per-row transform so we never
|
||||
# mutate the caller's (which already ran pre_call with its own
|
||||
# model/start_time/optional_params).
|
||||
batch_transform_logging_obj = Logging(
|
||||
model="",
|
||||
messages=[],
|
||||
|
|
@ -691,29 +755,27 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
|||
request=httpx.Request(method="POST", url="https://example.com"),
|
||||
)
|
||||
|
||||
# Transform all lines
|
||||
transformed_lines = []
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
# Transform each row straight into the output buffer, so peak memory
|
||||
# stays at ~one row plus the output. If any row fails, return the
|
||||
# original content unchanged.
|
||||
output = bytearray()
|
||||
for line in itertools.chain([first_line], lines):
|
||||
try:
|
||||
vertex_output = json.loads(line)
|
||||
openai_output = (
|
||||
self._transform_single_vertex_batch_output_to_openai(
|
||||
vertex_output=vertex_output,
|
||||
vertex_output=json.loads(line),
|
||||
vertex_gemini_config=vertex_gemini_config,
|
||||
logging_obj=batch_transform_logging_obj,
|
||||
mock_httpx_response=mock_httpx_response,
|
||||
)
|
||||
)
|
||||
transformed_lines.append(json.dumps(openai_output))
|
||||
except Exception:
|
||||
# If any line fails, return original content
|
||||
return content
|
||||
if output:
|
||||
output += b"\n"
|
||||
output += json.dumps(openai_output).encode("utf-8")
|
||||
|
||||
# Return transformed content
|
||||
return "\n".join(transformed_lines).encode("utf-8")
|
||||
return bytes(output)
|
||||
|
||||
except Exception:
|
||||
# If anything fails, return original content
|
||||
|
|
@ -795,137 +857,3 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig):
|
|||
"message": f"Failed to transform response: {str(e)}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VertexAIJsonlFilesTransformation(VertexGeminiConfig):
|
||||
"""
|
||||
Transforms OpenAI /v1/files/* requests to VertexAI /v1/files/* requests
|
||||
"""
|
||||
|
||||
def transform_openai_file_content_to_vertex_ai_file_content(
|
||||
self, openai_file_content: Optional[FileTypes] = None
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Transforms OpenAI FileContentRequest to VertexAI FileContentRequest
|
||||
"""
|
||||
|
||||
if openai_file_content is None:
|
||||
raise ValueError("contents of file are None")
|
||||
# Read the content of the file
|
||||
file_content = self._get_content_from_openai_file(openai_file_content)
|
||||
|
||||
# Split into lines and parse each line as JSON
|
||||
openai_jsonl_content = [
|
||||
json.loads(line) for line in file_content.splitlines() if line.strip()
|
||||
]
|
||||
vertex_jsonl_content = (
|
||||
self._transform_openai_jsonl_content_to_vertex_ai_jsonl_content(
|
||||
openai_jsonl_content
|
||||
)
|
||||
)
|
||||
vertex_jsonl_string = "\n".join(
|
||||
json.dumps(item) for item in vertex_jsonl_content
|
||||
)
|
||||
object_name = self._get_gcs_object_name(
|
||||
openai_jsonl_content=openai_jsonl_content
|
||||
)
|
||||
return vertex_jsonl_string, object_name
|
||||
|
||||
def _transform_openai_jsonl_content_to_vertex_ai_jsonl_content(
|
||||
self, openai_jsonl_content: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
return _openai_batch_jsonl_entries_to_vertex_wrapped_requests(
|
||||
openai_jsonl_content=openai_jsonl_content,
|
||||
map_openai_to_vertex_params=self._map_openai_to_vertex_params,
|
||||
)
|
||||
|
||||
def _get_gcs_object_name(
|
||||
self,
|
||||
openai_jsonl_content: List[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""
|
||||
Gets a unique GCS object name for the VertexAI batch prediction job
|
||||
|
||||
named as: litellm-vertex-{model}-{uuid}
|
||||
"""
|
||||
_model = openai_jsonl_content[0].get("body", {}).get("model", "")
|
||||
if "publishers/google/models" not in _model:
|
||||
_model = f"publishers/google/models/{_model}"
|
||||
safe_model_path = sanitize_cloud_object_path(_model, fallback="model")
|
||||
object_name = f"{VERTEX_AI_MANAGED_GCS_PREFIX}{safe_model_path}/{uuid.uuid4()}"
|
||||
return object_name
|
||||
|
||||
def _map_openai_to_vertex_params(
|
||||
self,
|
||||
openai_request_body: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
wrapper to call VertexGeminiConfig.map_openai_params
|
||||
"""
|
||||
_model = openai_request_body.get("model", "")
|
||||
vertex_params = self.map_openai_params(
|
||||
model=_model,
|
||||
non_default_params=openai_request_body,
|
||||
optional_params={},
|
||||
drop_params=False,
|
||||
)
|
||||
return vertex_params
|
||||
|
||||
def _get_content_from_openai_file(self, openai_file_content: FileTypes) -> str:
|
||||
"""
|
||||
Helper to extract content from various OpenAI file types and return as string.
|
||||
|
||||
Handles:
|
||||
- Direct content (str, bytes, IO[bytes])
|
||||
- Tuple formats: (filename, content, [content_type], [headers])
|
||||
- PathLike objects
|
||||
"""
|
||||
content: Union[str, bytes] = b""
|
||||
# Extract file content from tuple if necessary
|
||||
if isinstance(openai_file_content, tuple):
|
||||
# Take the second element which is always the file content
|
||||
file_content = openai_file_content[1]
|
||||
else:
|
||||
file_content = openai_file_content
|
||||
|
||||
# Handle different file content types
|
||||
if isinstance(file_content, str):
|
||||
# String content can be used directly
|
||||
content = file_content
|
||||
elif isinstance(file_content, bytes):
|
||||
# Bytes content can be decoded
|
||||
content = file_content
|
||||
elif isinstance(file_content, PathLike): # PathLike
|
||||
with open(str(file_content), "rb") as f:
|
||||
content = f.read()
|
||||
elif hasattr(file_content, "read"): # IO[bytes]
|
||||
# File-like objects need to be read
|
||||
content = file_content.read()
|
||||
|
||||
# Ensure content is string
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode("utf-8")
|
||||
|
||||
return content
|
||||
|
||||
def transform_gcs_bucket_response_to_openai_file_object(
|
||||
self, create_file_data: CreateFileRequest, gcs_upload_response: Dict[str, Any]
|
||||
) -> OpenAIFileObject:
|
||||
"""
|
||||
Transforms GCS Bucket upload file response to OpenAI FileObject
|
||||
"""
|
||||
gcs_id = gcs_upload_response.get("id", "")
|
||||
# Remove the last numeric ID from the path
|
||||
gcs_id = "/".join(gcs_id.split("/")[:-1]) if gcs_id else ""
|
||||
|
||||
return OpenAIFileObject(
|
||||
purpose=create_file_data.get("purpose", "batch"),
|
||||
id=f"gs://{gcs_id}",
|
||||
filename=gcs_upload_response.get("name", ""),
|
||||
created_at=_convert_vertex_datetime_to_openai_datetime(
|
||||
vertex_datetime=gcs_upload_response.get("timeCreated", "")
|
||||
),
|
||||
status="uploaded",
|
||||
bytes=gcs_upload_response.get("size", 0),
|
||||
object="file",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3544,9 +3544,18 @@ async def _virtual_key_max_budget_check(
|
|||
# so a NaN max_budget would silently disable enforcement. Treat a
|
||||
# non-finite max_budget as "no configured limit" rather than as a bypass.
|
||||
if math.isfinite(valid_token.max_budget) and spend >= valid_token.max_budget:
|
||||
# name the key in the error so operators don't have to reverse-map
|
||||
# spend back to a key; key_name is the masked form (last 4 chars)
|
||||
key_label = valid_token.key_alias or "key"
|
||||
key_descriptor = (
|
||||
f"{key_label} ({valid_token.key_name})"
|
||||
if valid_token.key_name
|
||||
else key_label
|
||||
)
|
||||
raise litellm.BudgetExceededError(
|
||||
current_cost=spend,
|
||||
max_budget=valid_token.max_budget,
|
||||
message=f"Budget has been exceeded! Key={key_descriptor} Current cost: {spend}, Max budget: {valid_token.max_budget}",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,18 +17,20 @@ Quick summary:
|
|||
- async_log_success_event() fires on GET /v1/batches/{id} (batch completion)
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Literal, Optional, Tuple, Union
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
import json
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.batches.batch_utils import (
|
||||
_count_entry_tokens,
|
||||
_estimate_batch_entry_tokens,
|
||||
_extract_file_access_credentials,
|
||||
_get_batch_job_input_file_usage,
|
||||
_get_file_content_as_dictionary,
|
||||
_get_models_from_batch_input_file_content,
|
||||
_iter_batch_input_lines,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -524,6 +526,8 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
|||
# Managed files require bypassing the HTTP endpoint (which runs access-check hooks)
|
||||
# and calling the managed files hook directly with the user's credentials.
|
||||
is_managed_file = _is_base64_encoded_unified_file_id(file_id)
|
||||
# For managed files the unified file id encodes the proxy model
|
||||
# alias(es) the file was uploaded for; auth validates against those.
|
||||
target_model_names = (
|
||||
get_models_from_unified_file_id(is_managed_file)
|
||||
if is_managed_file
|
||||
|
|
@ -555,7 +559,38 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
|||
f"Expected bytes content from file retrieval for {file_id}, "
|
||||
f"got {type(file_content_bytes)}"
|
||||
)
|
||||
file_content_as_dict = _get_file_content_as_dictionary(file_content_bytes)
|
||||
|
||||
# Single streaming pass over the JSONL lines, accounting each row
|
||||
# independently. One bad row can never abort the pass: a malformed
|
||||
# line is skipped (its request can't run upstream anyway) and a row
|
||||
# the token counter can't measure falls back to a conservative
|
||||
# size-based estimate. This guarantees two things a restricted caller
|
||||
# must not be able to break by crafting a row that raises:
|
||||
# 1. The allowlist check below always sees every parseable
|
||||
# ``body.model`` (the loop never stops early), so models can't be
|
||||
# smuggled in after a bad row.
|
||||
# 2. The token total is never silently zeroed, so the TPM limit
|
||||
# can't be evaded by sending uncountable rows.
|
||||
# Counting stays best-effort, so a legitimate (e.g. multimodal) row
|
||||
# the counter can't measure is estimated, not hard-rejected.
|
||||
models: set = set()
|
||||
total_tokens = 0
|
||||
request_count = 0
|
||||
for raw_line in _iter_batch_input_lines(file_content_bytes):
|
||||
request_count += 1
|
||||
try:
|
||||
entry = json.loads(raw_line)
|
||||
except Exception:
|
||||
total_tokens += _estimate_batch_entry_tokens(raw_line)
|
||||
continue
|
||||
if isinstance(entry, dict):
|
||||
model = (entry.get("body") or {}).get("model")
|
||||
if model:
|
||||
models.add(model)
|
||||
try:
|
||||
total_tokens += _count_entry_tokens(entry)
|
||||
except Exception:
|
||||
total_tokens += _estimate_batch_entry_tokens(raw_line)
|
||||
|
||||
# Validate every model named in the batch JSONL against the
|
||||
# caller's per-key model allowlist. Without this, a caller
|
||||
|
|
@ -565,17 +600,12 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
|||
if user_api_key_dict is not None:
|
||||
await self._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
file_content_as_dict=file_content_as_dict,
|
||||
models=models,
|
||||
target_model_names=target_model_names or None,
|
||||
)
|
||||
|
||||
input_file_usage = _get_batch_job_input_file_usage(
|
||||
file_content_dictionary=file_content_as_dict,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
request_count = len(file_content_as_dict)
|
||||
return BatchFileUsage(
|
||||
total_tokens=input_file_usage.total_tokens,
|
||||
total_tokens=total_tokens,
|
||||
request_count=request_count,
|
||||
)
|
||||
|
||||
|
|
@ -601,14 +631,15 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
|||
async def _enforce_batch_file_model_access(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
file_content_as_dict: List[dict],
|
||||
models: Optional[Iterable[str]] = None,
|
||||
target_model_names: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Reject the batch if the caller is not authorized for the upload target.
|
||||
|
||||
For managed files, ``target_model_names`` (from the unified file id) is
|
||||
the proxy alias the file was uploaded for and is used directly for auth.
|
||||
For legacy/non-managed files, falls back to ``body.model`` values in the JSONL.
|
||||
the proxy alias the file was uploaded for and is checked directly.
|
||||
Otherwise the ``body.model`` values collected from the JSONL (``models``)
|
||||
are checked.
|
||||
|
||||
Reuses standard auth helpers so the same model access rules the proxy
|
||||
enforces on `/chat/completions` apply here.
|
||||
|
|
@ -627,10 +658,9 @@ class _PROXY_BatchRateLimiter(CustomLogger):
|
|||
|
||||
if target_model_names:
|
||||
models = target_model_names
|
||||
else:
|
||||
models = _get_models_from_batch_input_file_content(file_content_as_dict)
|
||||
if not models:
|
||||
return
|
||||
|
||||
if not models:
|
||||
return
|
||||
|
||||
team_object = None
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class _ProxyDBLogger(CustomLogger):
|
|||
kwargs, response_obj, start_time, end_time
|
||||
)
|
||||
|
||||
async def async_post_call_failure_hook(
|
||||
async def async_post_call_failure_hook( # noqa: PLR0915
|
||||
self,
|
||||
request_data: dict,
|
||||
original_exception: Exception,
|
||||
|
|
@ -162,9 +162,20 @@ class _ProxyDBLogger(CustomLogger):
|
|||
if obj_start is not None:
|
||||
actual_start_time = obj_start
|
||||
|
||||
# A stream that broke mid-flight still billed the provider for the
|
||||
# chunks already delivered. ``post_call_failure_hook`` lifts that
|
||||
# recovered cost onto request_data (the usage rides along in
|
||||
# ``combined_usage_object`` for the token columns), so attribute the
|
||||
# real partial spend to this failure row instead of zero.
|
||||
recovered_response_cost = 0.0
|
||||
if isinstance(request_data.get("combined_usage_object"), litellm.Usage):
|
||||
recovered_response_cost = max(
|
||||
float(request_data.get("response_cost") or 0.0), 0.0
|
||||
)
|
||||
|
||||
await proxy_logging_obj.db_spend_update_writer.update_database(
|
||||
token=user_api_key_dict.api_key,
|
||||
response_cost=0.0,
|
||||
response_cost=recovered_response_cost,
|
||||
user_id=user_api_key_dict.user_id,
|
||||
end_user_id=user_api_key_dict.end_user_id,
|
||||
team_id=user_api_key_dict.team_id,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import asyncio
|
||||
import traceback
|
||||
from typing import Any, Optional, cast, get_args
|
||||
from typing import Any, BinaryIO, Optional, Union, cast, get_args
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
|
|
@ -92,16 +92,18 @@ def get_files_provider_config(
|
|||
return None
|
||||
|
||||
|
||||
def get_first_json_object(file_content_bytes: bytes) -> Optional[dict]:
|
||||
def get_first_json_object(file_source: Union[bytes, BinaryIO]) -> Optional[dict]:
|
||||
try:
|
||||
# Decode the bytes to a string and split into lines
|
||||
file_content = file_content_bytes.decode("utf-8")
|
||||
first_line = file_content.splitlines()[0].strip()
|
||||
|
||||
# Parse the JSON object from the first line
|
||||
json_object = json.loads(first_line)
|
||||
return json_object
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
if isinstance(file_source, (bytes, bytearray)):
|
||||
newline = file_source.find(b"\n")
|
||||
raw = file_source if newline == -1 else file_source[:newline]
|
||||
first_line = raw.decode("utf-8")
|
||||
else:
|
||||
file_source.seek(0)
|
||||
first_line = file_source.readline().decode("utf-8")
|
||||
file_source.seek(0)
|
||||
return json.loads(first_line.strip())
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -322,9 +324,15 @@ async def create_file( # noqa: PLR0915
|
|||
|
||||
data: Dict = {}
|
||||
try:
|
||||
# Use orjson to parse JSON data, orjson speeds up requests significantly
|
||||
# Read the file content
|
||||
file_content = await file.read()
|
||||
# Batch uploads can be gigabytes. Starlette has already spooled the upload
|
||||
# to disk, so stream from that handle instead of reading it into memory.
|
||||
# Other uploads are small and stay in-memory bytes.
|
||||
file_source: Union[bytes, BinaryIO]
|
||||
if purpose == "batch":
|
||||
await file.seek(0)
|
||||
file_source = file.file
|
||||
else:
|
||||
file_source = await file.read()
|
||||
custom_llm_provider = (
|
||||
provider
|
||||
or get_custom_llm_provider_from_request_headers(request=request)
|
||||
|
|
@ -444,13 +452,13 @@ async def create_file( # noqa: PLR0915
|
|||
)
|
||||
|
||||
# Prepare the file data according to FileTypes
|
||||
file_data = (file.filename, file_content, file.content_type)
|
||||
file_data = (file.filename, file_source, file.content_type)
|
||||
|
||||
## check if model is a loadbalanced model
|
||||
router_model: Optional[str] = None
|
||||
is_router_model = False
|
||||
if litellm.enable_loadbalancing_on_batch_endpoints is True:
|
||||
json_obj = get_first_json_object(file_content_bytes=file_content)
|
||||
json_obj = get_first_json_object(file_source)
|
||||
if json_obj:
|
||||
router_model = get_model_from_json_obj(json_object=json_obj)
|
||||
is_router_model = is_known_model(
|
||||
|
|
|
|||
|
|
@ -6,18 +6,29 @@ import httpx
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.litellm_core_utils.core_helpers import map_finish_reason
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.litellm_logging import use_custom_pricing_for_model
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
get_content_from_model_response,
|
||||
)
|
||||
from litellm.llms.anthropic import get_anthropic_config
|
||||
from litellm.llms.anthropic.chat.handler import (
|
||||
ModelResponseIterator as AnthropicModelResponseIterator,
|
||||
)
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
from litellm.proxy._types import PassThroughEndpointLoggingTypedDict
|
||||
from litellm.proxy.auth.auth_utils import get_end_user_id_from_request_body
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
PassthroughStandardLoggingPayload,
|
||||
)
|
||||
from litellm.types.utils import LiteLLMBatch, ModelResponse, TextCompletionResponse
|
||||
from litellm.types.utils import (
|
||||
Choices,
|
||||
LiteLLMBatch,
|
||||
Message,
|
||||
ModelResponse,
|
||||
TextCompletionResponse,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType
|
||||
|
|
@ -136,6 +147,84 @@ class AnthropicPassthroughLoggingHandler:
|
|||
return model
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _stream_was_interrupted(
|
||||
all_chunks: Sequence[Union[str, bytes]],
|
||||
) -> bool:
|
||||
"""
|
||||
Anthropic ends a stream with ``content_block_stop`` -> ``message_delta``
|
||||
-> ``message_stop``; a client disconnect leaves the last event mid
|
||||
``content_block_delta``. Scan from the tail and decide on the first
|
||||
terminal-region event, so the common completed case is O(1) rather than
|
||||
re-deserializing every line of the stream.
|
||||
"""
|
||||
for raw in reversed(all_chunks):
|
||||
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
||||
for line in reversed(text.splitlines()):
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line[len("data:") :].strip())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
etype = data.get("type")
|
||||
if etype == "message_delta":
|
||||
return False
|
||||
if etype in (
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"message_start",
|
||||
):
|
||||
return True
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _recover_interrupted_stream_output_tokens(
|
||||
response: Union[ModelResponse, TextCompletionResponse],
|
||||
all_chunks: Sequence[Union[str, bytes]],
|
||||
model: str,
|
||||
) -> None:
|
||||
"""
|
||||
An Anthropic stream interrupted before its terminal ``message_delta``
|
||||
(client disconnect) carries only the ``message_start`` ``output_tokens``
|
||||
placeholder (typically 1-3), so completion tokens and spend are
|
||||
undercounted ~20x. Re-tokenize the buffered output text to recover a
|
||||
realistic ``output_tokens`` for usage/cost. Completed streams are
|
||||
untouched because their terminal ``message_delta`` short-circuits here.
|
||||
"""
|
||||
if not isinstance(response, ModelResponse):
|
||||
return
|
||||
if not AnthropicPassthroughLoggingHandler._stream_was_interrupted(all_chunks):
|
||||
return
|
||||
usage = getattr(response, "usage", None)
|
||||
if usage is None:
|
||||
return
|
||||
output_text = get_content_from_model_response(response)
|
||||
if not output_text:
|
||||
return
|
||||
try:
|
||||
recovered_output_tokens = litellm.token_counter(
|
||||
model=model, text=output_text, count_response_tokens=True
|
||||
)
|
||||
except Exception:
|
||||
verbose_proxy_logger.warning(
|
||||
"Could not re-tokenize interrupted stream output; "
|
||||
"keeping placeholder completion token count."
|
||||
)
|
||||
return
|
||||
if recovered_output_tokens <= (usage.completion_tokens or 0):
|
||||
return
|
||||
usage.completion_tokens = recovered_output_tokens
|
||||
usage.total_tokens = (usage.prompt_tokens or 0) + recovered_output_tokens
|
||||
# Anthropic costing reads completion_tokens_details.text_tokens, so the
|
||||
# stale message_start placeholder there must be corrected too or spend
|
||||
# stays undercounted even after completion_tokens is fixed.
|
||||
details = getattr(usage, "completion_tokens_details", None)
|
||||
if details is not None and getattr(details, "text_tokens", None) is not None:
|
||||
details.text_tokens = recovered_output_tokens
|
||||
|
||||
@staticmethod
|
||||
def _create_anthropic_response_logging_payload(
|
||||
litellm_model_response: Union[ModelResponse, TextCompletionResponse],
|
||||
|
|
@ -191,6 +280,9 @@ class AnthropicPassthroughLoggingHandler:
|
|||
|
||||
kwargs["response_cost"] = response_cost
|
||||
kwargs["model"] = model
|
||||
# the pass-through success path reads spend from
|
||||
# model_call_details["response_cost"], not from kwargs
|
||||
logging_obj.model_call_details["response_cost"] = response_cost
|
||||
passthrough_logging_payload: Optional[PassthroughStandardLoggingPayload] = ( # type: ignore
|
||||
kwargs.get("passthrough_logging_payload")
|
||||
)
|
||||
|
|
@ -262,13 +354,42 @@ class AnthropicPassthroughLoggingHandler:
|
|||
if chunk_model:
|
||||
model = chunk_model
|
||||
|
||||
complete_streaming_response = (
|
||||
AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
|
||||
all_chunks=all_chunks,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
model=model,
|
||||
try:
|
||||
complete_streaming_response = (
|
||||
AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
|
||||
all_chunks=all_chunks,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
model=model,
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
# stream_chunk_builder re-raises assembly failures (as litellm.APIError)
|
||||
# on large agentic tool-use / thinking streams; treat that the same as a
|
||||
# None result so the usage-only fallback below still recovers cost
|
||||
verbose_proxy_logger.warning(
|
||||
"Anthropic passthrough: stream assembly raised (model=%s): %s; falling "
|
||||
"back to usage-only cost from raw SSE events.",
|
||||
model,
|
||||
e,
|
||||
)
|
||||
complete_streaming_response = None
|
||||
if complete_streaming_response is None:
|
||||
# stream_chunk_builder cannot always reassemble large agentic streams, but
|
||||
# Anthropic still emits token usage in the message_start / message_delta SSE
|
||||
# events regardless of content shape; recover usage-only so cost is tracked.
|
||||
# Guard it too: a raise here would defeat the point and drop the request
|
||||
try:
|
||||
complete_streaming_response = AnthropicPassthroughLoggingHandler._build_usage_only_response_from_chunks(
|
||||
all_chunks=all_chunks,
|
||||
model=model,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Anthropic passthrough: usage-only fallback failed (model=%s): %s",
|
||||
model,
|
||||
e,
|
||||
)
|
||||
complete_streaming_response = None
|
||||
if complete_streaming_response is None:
|
||||
verbose_proxy_logger.error(
|
||||
"Unable to build complete streaming response for Anthropic passthrough endpoint, not logging..."
|
||||
|
|
@ -277,6 +398,11 @@ class AnthropicPassthroughLoggingHandler:
|
|||
"result": None,
|
||||
"kwargs": {},
|
||||
}
|
||||
AnthropicPassthroughLoggingHandler._recover_interrupted_stream_output_tokens(
|
||||
response=complete_streaming_response,
|
||||
all_chunks=all_chunks,
|
||||
model=model,
|
||||
)
|
||||
kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
|
||||
litellm_model_response=complete_streaming_response,
|
||||
model=model,
|
||||
|
|
@ -550,6 +676,141 @@ class AnthropicPassthroughLoggingHandler:
|
|||
)
|
||||
return complete_streaming_response
|
||||
|
||||
@staticmethod
|
||||
def _extract_sse_data(event_str: str) -> Optional[dict]:
|
||||
"""Parse the JSON object from the ``data:`` line of an Anthropic SSE event."""
|
||||
for line in event_str.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("data:"):
|
||||
payload = stripped[len("data:") :].strip()
|
||||
if not payload or payload == "[DONE]":
|
||||
return None
|
||||
try:
|
||||
return cast(dict, json.loads(payload))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_usage_only_response_from_chunks( # noqa: PLR0915
|
||||
all_chunks: Sequence[Union[str, bytes]],
|
||||
model: str,
|
||||
) -> Optional[ModelResponse]:
|
||||
"""
|
||||
Build a usage-bearing ModelResponse from Anthropic SSE token-usage events, for
|
||||
cost tracking when stream_chunk_builder cannot reassemble the stream.
|
||||
|
||||
Anthropic emits usage in ``message_start`` (uncached input + cache tokens, and an
|
||||
initial output_tokens) and the final ``message_delta`` (cumulative output_tokens)
|
||||
regardless of the content/tool shape, so cost is recoverable even when full
|
||||
content assembly fails. Returns ``None`` if no usage event is found.
|
||||
"""
|
||||
input_tokens = 0
|
||||
cache_read = 0
|
||||
cache_creation = 0
|
||||
cache_creation_5m: Optional[int] = None
|
||||
cache_creation_1h: Optional[int] = None
|
||||
output_tokens = 0
|
||||
web_search_requests: Optional[int] = None
|
||||
tool_search_requests: Optional[int] = None
|
||||
inference_geo: Optional[str] = None
|
||||
stop_reason: Optional[str] = None
|
||||
found_usage = False
|
||||
resolved_model = model
|
||||
for _chunk_str in all_chunks:
|
||||
for (
|
||||
event_str
|
||||
) in AnthropicPassthroughLoggingHandler._split_sse_chunk_into_events(
|
||||
_chunk_str
|
||||
):
|
||||
data = AnthropicPassthroughLoggingHandler._extract_sse_data(event_str)
|
||||
if not data:
|
||||
continue
|
||||
event_type = data.get("type")
|
||||
if event_type == "message_start":
|
||||
message = data.get("message") or {}
|
||||
if not resolved_model or resolved_model == "unknown":
|
||||
resolved_model = message.get("model") or resolved_model
|
||||
usage = message.get("usage") or {}
|
||||
input_tokens = usage.get("input_tokens") or input_tokens
|
||||
cache_read = usage.get("cache_read_input_tokens") or cache_read
|
||||
cache_creation = (
|
||||
usage.get("cache_creation_input_tokens") or cache_creation
|
||||
)
|
||||
_cc = usage.get("cache_creation")
|
||||
if isinstance(_cc, dict):
|
||||
cache_creation_5m = _cc.get("ephemeral_5m_input_tokens")
|
||||
cache_creation_1h = _cc.get("ephemeral_1h_input_tokens")
|
||||
if usage.get("inference_geo") is not None:
|
||||
inference_geo = usage.get("inference_geo")
|
||||
if usage.get("output_tokens") is not None:
|
||||
output_tokens = usage.get("output_tokens")
|
||||
found_usage = True
|
||||
elif event_type == "message_delta":
|
||||
_delta_stop = (data.get("delta") or {}).get("stop_reason")
|
||||
if _delta_stop:
|
||||
stop_reason = _delta_stop
|
||||
usage = data.get("usage") or {}
|
||||
if usage.get("output_tokens") is not None:
|
||||
output_tokens = usage.get("output_tokens")
|
||||
_stu = usage.get("server_tool_use")
|
||||
if isinstance(_stu, dict):
|
||||
if _stu.get("web_search_requests") is not None:
|
||||
web_search_requests = _stu.get("web_search_requests")
|
||||
if _stu.get("tool_search_requests") is not None:
|
||||
tool_search_requests = _stu.get("tool_search_requests")
|
||||
if usage.get("cache_read_input_tokens") is not None:
|
||||
cache_read = usage.get("cache_read_input_tokens")
|
||||
if usage.get("inference_geo") is not None:
|
||||
inference_geo = usage.get("inference_geo")
|
||||
found_usage = True
|
||||
if not found_usage:
|
||||
return None
|
||||
# If only the 5m/1h split was provided, derive the cache_creation total from it.
|
||||
if not cache_creation and (cache_creation_5m or cache_creation_1h):
|
||||
cache_creation = (cache_creation_5m or 0) + (cache_creation_1h or 0)
|
||||
# build usage via the same AnthropicConfig.calculate_usage path the success
|
||||
# cases use, so prompt_tokens are cache-inclusive and cache / server_tool_use /
|
||||
# inference_geo tokens are priced instead of left at $0
|
||||
usage_object: dict = {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
if cache_read:
|
||||
usage_object["cache_read_input_tokens"] = cache_read
|
||||
if cache_creation:
|
||||
usage_object["cache_creation_input_tokens"] = cache_creation
|
||||
if cache_creation_5m is not None or cache_creation_1h is not None:
|
||||
usage_object["cache_creation"] = {
|
||||
"ephemeral_5m_input_tokens": cache_creation_5m or 0,
|
||||
"ephemeral_1h_input_tokens": cache_creation_1h or 0,
|
||||
}
|
||||
if web_search_requests is not None or tool_search_requests is not None:
|
||||
_server_tool_use: dict = {}
|
||||
if web_search_requests is not None:
|
||||
_server_tool_use["web_search_requests"] = web_search_requests
|
||||
if tool_search_requests is not None:
|
||||
_server_tool_use["tool_search_requests"] = tool_search_requests
|
||||
usage_object["server_tool_use"] = _server_tool_use
|
||||
if inference_geo is not None:
|
||||
usage_object["inference_geo"] = inference_geo
|
||||
usage_obj = AnthropicConfig().calculate_usage(
|
||||
usage_object=usage_object, reasoning_content=None
|
||||
)
|
||||
return ModelResponse(
|
||||
model=resolved_model,
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason=(
|
||||
map_finish_reason(stop_reason) if stop_reason else "stop"
|
||||
),
|
||||
index=0,
|
||||
message=Message(role="assistant", content=""),
|
||||
)
|
||||
],
|
||||
usage=usage_obj,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def batch_creation_handler( # noqa: PLR0915
|
||||
httpx_response: httpx.Response,
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@ class BasePassthroughLoggingHandler(ABC):
|
|||
|
||||
kwargs["response_cost"] = response_cost
|
||||
kwargs["model"] = model
|
||||
# the pass-through success path reads spend from
|
||||
# model_call_details["response_cost"], not from kwargs
|
||||
logging_obj.model_call_details["response_cost"] = response_cost
|
||||
passthrough_logging_payload: Optional[PassthroughStandardLoggingPayload] = ( # type: ignore
|
||||
kwargs.get("passthrough_logging_payload")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -285,8 +285,10 @@ class PassThroughStreamingHandler:
|
|||
Returns:
|
||||
List of string lines, with each line being a complete data: {} chunk
|
||||
"""
|
||||
# Combine all bytes and decode to string
|
||||
combined_str = b"".join(raw_bytes).decode("utf-8")
|
||||
# errors="replace" so a stream cut mid-multibyte-sequence (client disconnect)
|
||||
# still decodes and logs the usage events already received, instead of raising
|
||||
# and dropping the whole request from SpendLogs
|
||||
combined_str = b"".join(raw_bytes).decode("utf-8", errors="replace")
|
||||
|
||||
# Split by newlines and filter out empty lines
|
||||
lines = [line.strip() for line in combined_str.split("\n") if line.strip()]
|
||||
|
|
|
|||
|
|
@ -265,6 +265,13 @@ def get_logging_payload( # noqa: PLR0915
|
|||
elif isinstance(_usage, dict):
|
||||
usage = _usage
|
||||
|
||||
# A request that failed mid-stream has no usable response_obj usage, but the
|
||||
# streaming handler may have recovered the usage from the chunks already
|
||||
# delivered. Honor that override so the partial usage lands in spend tracking.
|
||||
_combined_usage = kwargs.get("combined_usage_object")
|
||||
if not usage and isinstance(_combined_usage, litellm.Usage):
|
||||
usage = _combined_usage.model_dump()
|
||||
|
||||
id = get_spend_logs_id(call_type or "acompletion", response_obj_dict, kwargs)
|
||||
standard_logging_payload = cast(
|
||||
Optional[StandardLoggingPayload], kwargs.get("standard_logging_object", None)
|
||||
|
|
|
|||
|
|
@ -2111,12 +2111,21 @@ class ProxyLogging:
|
|||
# compute preprocessing latency after the logging object is popped.
|
||||
_logging_obj = request_data.get("litellm_logging_obj")
|
||||
if _logging_obj is not None:
|
||||
_first_handoff = getattr(_logging_obj, "model_call_details", {}).get(
|
||||
"first_api_call_start_time"
|
||||
)
|
||||
_model_call_details = getattr(_logging_obj, "model_call_details", {})
|
||||
_first_handoff = _model_call_details.get("first_api_call_start_time")
|
||||
if _first_handoff is not None:
|
||||
request_data["first_api_call_start_time"] = _first_handoff
|
||||
|
||||
# A stream that broke mid-flight still billed the provider for the
|
||||
# chunks already delivered; the streaming handler stashes that
|
||||
# recovered usage and cost here. Lift them onto request_data so the
|
||||
# failure-path spend callbacks (which run after the logging object
|
||||
# is popped) record the real partial spend instead of zero.
|
||||
_recovered_usage = _model_call_details.get("combined_usage_object")
|
||||
if _recovered_usage is not None:
|
||||
request_data["combined_usage_object"] = _recovered_usage
|
||||
request_data["response_cost"] = _model_call_details.get("response_cost")
|
||||
|
||||
# Remove before callbacks iterate — not serialisable
|
||||
request_data.pop("litellm_logging_obj", None)
|
||||
|
||||
|
|
|
|||
|
|
@ -82,43 +82,83 @@ def replace_model_in_jsonl(file_content: FileTypes, new_model_name: str) -> File
|
|||
if isinstance(file_content, PathLike):
|
||||
return file_content
|
||||
|
||||
# Decode the bytes to a string and split into lines
|
||||
# If file_content is a file-like object, read the bytes
|
||||
if hasattr(file_content, "read"):
|
||||
file_content_bytes = file_content.read() # type: ignore
|
||||
elif isinstance(file_content, tuple):
|
||||
file_content_bytes = file_content[1]
|
||||
else:
|
||||
file_content_bytes = file_content
|
||||
|
||||
# Decode the bytes to a string and split into lines
|
||||
if isinstance(file_content_bytes, bytes):
|
||||
file_content_str = file_content_bytes.decode("utf-8")
|
||||
elif isinstance(file_content_bytes, str):
|
||||
file_content_str = file_content_bytes
|
||||
# Iterate the source line-by-line WITHOUT reading it all into memory. A
|
||||
# spooled upload handle (managed batches stream from it) is read straight
|
||||
# off its backing; bytes/str are wrapped so they iterate line-by-line.
|
||||
source = file_content[1] if isinstance(file_content, tuple) else file_content
|
||||
if hasattr(source, "read"):
|
||||
if hasattr(source, "seek"):
|
||||
try:
|
||||
source.seek(0) # type: ignore[attr-defined]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
line_iter: object = source
|
||||
elif isinstance(source, (bytes, bytearray)):
|
||||
line_iter = io.BytesIO(bytes(source))
|
||||
elif isinstance(source, str):
|
||||
line_iter = io.StringIO(source)
|
||||
else:
|
||||
return file_content
|
||||
|
||||
# Parse JSONL properly, handling potential multiline JSON objects
|
||||
json_objects = parse_jsonl_with_embedded_newlines(file_content_str)
|
||||
# Rewrite one row at a time, writing straight into the output buffer
|
||||
# instead of holding every parsed row in a list. Peak memory stays at
|
||||
# ~one row plus the output rather than several full copies of the file,
|
||||
# which the managed-files path depends on (it re-runs this rewrite once
|
||||
# per target model). Lines are accumulated so JSON objects that span
|
||||
# multiple physical lines still parse. Streaming the handle also means
|
||||
# the model rewrite is actually applied to tuple-wrapped upload handles;
|
||||
# otherwise a restricted body.model would survive and bypass the batch
|
||||
# model allowlist (which validates the upload target alias).
|
||||
output = InMemoryFile(
|
||||
b"", name="modified_file.jsonl", content_type="application/jsonl"
|
||||
)
|
||||
wrote_any = False
|
||||
buffer = ""
|
||||
for raw_line in line_iter: # type: ignore[attr-defined]
|
||||
buffer += (
|
||||
raw_line.decode("utf-8")
|
||||
if isinstance(raw_line, (bytes, bytearray))
|
||||
else raw_line
|
||||
)
|
||||
stripped = buffer.strip()
|
||||
if not stripped:
|
||||
buffer = ""
|
||||
continue
|
||||
try:
|
||||
json_object = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
continue # object not complete yet; keep accumulating
|
||||
if isinstance(json_object, dict) and isinstance(
|
||||
json_object.get("body"), dict
|
||||
):
|
||||
json_object["body"]["model"] = new_model_name
|
||||
output.write(
|
||||
(("\n" if wrote_any else "") + json.dumps(json_object)).encode("utf-8")
|
||||
)
|
||||
wrote_any = True
|
||||
buffer = ""
|
||||
|
||||
if buffer.strip():
|
||||
# A row never parsed (truncated/malformed, or it swallowed the rows
|
||||
# that followed it). Returning the partial `output` would silently
|
||||
# drop those rows; return the unchanged original so the provider
|
||||
# rejects the batch loudly instead of accepting a truncated one.
|
||||
verbose_logger.error(
|
||||
f"error parsing trailing batch content: {buffer[:100]}..."
|
||||
)
|
||||
if hasattr(source, "seek"):
|
||||
try:
|
||||
source.seek(0) # type: ignore[attr-defined]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return file_content
|
||||
|
||||
# If no valid JSON objects were found, return the original content
|
||||
if len(json_objects) == 0:
|
||||
if not wrote_any:
|
||||
return file_content
|
||||
|
||||
modified_lines = []
|
||||
for json_object in json_objects:
|
||||
# Replace the model name if it exists
|
||||
if "body" in json_object:
|
||||
json_object["body"]["model"] = new_model_name
|
||||
|
||||
# Convert the modified JSON object back to a string
|
||||
modified_lines.append(json.dumps(json_object))
|
||||
|
||||
# Reassemble the modified lines and return as bytes
|
||||
modified_file_content = "\n".join(modified_lines).encode("utf-8")
|
||||
|
||||
return InMemoryFile(modified_file_content, name="modified_file.jsonl", content_type="application/jsonl") # type: ignore
|
||||
output.seek(0)
|
||||
return output # type: ignore
|
||||
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
|
||||
# return the original file content if there is an error replacing the model name
|
||||
|
|
|
|||
|
|
@ -321,3 +321,21 @@ class TwoStepFileUploadConfig(TypedDict, total=False):
|
|||
upload_request: Required[TwoStepFileUploadRequest]
|
||||
upload_url_location: Required[Literal["headers", "body"]]
|
||||
upload_url_key: str
|
||||
|
||||
|
||||
class ResumableChunkedUploadConfig(TypedDict, total=False):
|
||||
"""Drives a memory-bounded resumable upload (GCS JSON API).
|
||||
|
||||
The handler POSTs to the upload URL to open a session, reads the session URI
|
||||
from ``session_url_header``, then PUTs ``body_stream`` to that URI in
|
||||
``chunk_size``-byte chunks (a 256 KiB multiple) using Content-Range, so the
|
||||
payload is never buffered in full and the transfer is resumable.
|
||||
|
||||
``body_stream`` is a ``BaseFileUploadStream``; it is typed ``Any`` here to
|
||||
avoid importing the llms layer into types.
|
||||
"""
|
||||
|
||||
body_stream: Required[Any]
|
||||
chunk_size: int
|
||||
session_url_header: str
|
||||
initiate_headers: Dict[str, str]
|
||||
|
|
|
|||
|
|
@ -173,6 +173,9 @@ class CredentialLiteLLMParams(BaseModel):
|
|||
## UNIFIED PROJECT/REGION ##
|
||||
region_name: Optional[str] = None
|
||||
|
||||
## OBJECT STORAGE (files / batches) ##
|
||||
gcs_bucket_name: Optional[str] = None
|
||||
|
||||
## AWS BEDROCK / SAGEMAKER ##
|
||||
aws_access_key_id: Optional[str] = None
|
||||
aws_secret_access_key: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9
|
||||
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
|
||||
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
|
||||
|
||||
FROM $UV_IMAGE AS uvbin
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ proxy = [
|
|||
"fastapi-sso>=0.19.0,<1.0",
|
||||
"PyJWT>=2.12.0,<3.0",
|
||||
"python-multipart>=0.0.27,<1.0",
|
||||
"cryptography>=46.0.7,<47.0",
|
||||
"cryptography>=48.0.1,<49.0",
|
||||
"pynacl>=1.6.2,<2.0",
|
||||
"websockets>=15.0.1,<16.0",
|
||||
"boto3>=1.43.1,<2.0",
|
||||
|
|
@ -63,7 +63,7 @@ proxy = [
|
|||
"azure-storage-blob>=12.28.0,<13.0",
|
||||
"mcp>=1.26.0,<2.0",
|
||||
"litellm-proxy-extras==0.4.74",
|
||||
"litellm-enterprise==0.1.42",
|
||||
"litellm-enterprise==0.1.42.post2",
|
||||
"RestrictedPython>=8.1,<9.0",
|
||||
"rich>=13.9.4,<14.0",
|
||||
"polars>=1.38.1,<2.0",
|
||||
|
|
@ -170,7 +170,7 @@ dev = [
|
|||
"parameterized==0.9.0",
|
||||
"openapi-core==0.22.0; python_version < '3.14'",
|
||||
"pytest-timeout==2.4.0",
|
||||
"vcrpy==8.1.1",
|
||||
"vcrpy==8.2.1",
|
||||
"pytest-recording==0.13.4",
|
||||
]
|
||||
proxy-dev = [
|
||||
|
|
@ -196,7 +196,7 @@ ci = [
|
|||
"pytest-codspeed==4.3.0",
|
||||
"pytest-retry==1.7.0",
|
||||
"pyarrow==23.0.1",
|
||||
"langchain==1.2.10",
|
||||
"langchain==1.3.9",
|
||||
"lunary==1.4.36; python_version == '3.10'",
|
||||
"lunary==1.4.37; python_version >= '3.11'",
|
||||
"logfire==4.6.0",
|
||||
|
|
@ -214,11 +214,8 @@ ci = [
|
|||
"pyright==1.1.408",
|
||||
"langchain-mcp-adapters==0.2.1",
|
||||
"langchain-openai==1.1.14",
|
||||
"langgraph==1.0.10",
|
||||
# langgraph-prebuilt 1.0.9 imports ExecutionInfo/ServerInfo from
|
||||
# langgraph.runtime, which is not exported until langgraph 1.1.0.
|
||||
# Pin to 1.0.8 so it pairs correctly with langgraph==1.0.10.
|
||||
"langgraph-prebuilt==1.0.8",
|
||||
"langgraph>=1.2.4,<1.3.0",
|
||||
"langgraph-prebuilt>=1.1.0,<1.3.0",
|
||||
"claude-agent-sdk==0.1.44",
|
||||
]
|
||||
healthcheck = [
|
||||
|
|
@ -233,7 +230,7 @@ build-backend = "uv_build"
|
|||
[tool.uv]
|
||||
constraint-dependencies = [
|
||||
"tornado>=6.5.6",
|
||||
"aiohttp>=3.13.5,<3.14",
|
||||
"aiohttp>=3.14.1,<4.0",
|
||||
]
|
||||
default-groups = ["dev"]
|
||||
required-version = ">=0.10.9"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from litellm.integrations.custom_logger import CustomLogger
|
|||
from litellm.types.utils import StandardLoggingPayload
|
||||
import socket
|
||||
import httpx
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
|
||||
def _can_resolve_openai():
|
||||
|
|
@ -513,10 +513,26 @@ async def test_avertex_batch_prediction(monkeypatch):
|
|||
mock_response.status_code = 200
|
||||
return mock_response
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
side_effect=mock_side_effect,
|
||||
) as mock_global_post:
|
||||
# Batch jsonl file creation now streams to a GCS resumable session via
|
||||
# _aresumable_chunked_upload (httpx send), not AsyncHTTPHandler.post, so mock
|
||||
# that entry point to return the GCS object response. The resumable protocol
|
||||
# itself is covered in test_vertex_ai_files_streaming.py.
|
||||
mock_upload_response = httpx.Response(
|
||||
200,
|
||||
json=mock_file_response,
|
||||
request=httpx.Request("PUT", "https://storage.googleapis.com/upload"),
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
side_effect=mock_side_effect,
|
||||
) as mock_global_post,
|
||||
patch(
|
||||
"litellm.llms.custom_httpx.llm_http_handler.BaseLLMHTTPHandler._aresumable_chunked_upload",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_upload_response,
|
||||
),
|
||||
):
|
||||
litellm.set_verbose = True
|
||||
litellm._turn_on_debug()
|
||||
file_name = "vertex_batch_completions.jsonl"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import sys
|
||||
import os
|
||||
import traceback
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../..")
|
||||
) # Adds the parent directory to the system path
|
||||
from litellm import Router
|
||||
import pytest
|
||||
import litellm
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
|
@ -76,6 +69,29 @@ def test_tuple_input(sample_jsonl_bytes):
|
|||
assert result.content_type == "application/jsonl"
|
||||
|
||||
|
||||
def test_tuple_with_file_handle_rewrites_model(sample_jsonl_bytes):
|
||||
"""Security regression: when the tuple's content element is a file handle
|
||||
(batch uploads stream from the spooled upload handle), the model must still
|
||||
be rewritten. Otherwise a restricted body.model survives unmodified and
|
||||
bypasses the batch model allowlist, which only checks the upload target."""
|
||||
new_model = "approved-target-model"
|
||||
handle = BytesIO(sample_jsonl_bytes)
|
||||
test_tuple = ("test.jsonl", handle, "application/json")
|
||||
|
||||
result = replace_model_in_jsonl(test_tuple, new_model)
|
||||
|
||||
assert isinstance(result, InMemoryFile)
|
||||
rows = [
|
||||
json.loads(line)
|
||||
for line in result.getvalue().decode("utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
assert rows, "rewrite must produce rows"
|
||||
# every row now carries the rewritten target, not the original (restricted) model
|
||||
assert all(row["body"]["model"] == new_model for row in rows)
|
||||
assert all(row["body"]["model"] != "gpt-5.5" for row in rows)
|
||||
|
||||
|
||||
def test_file_like_object(sample_file_like):
|
||||
"""Test with file-like object input"""
|
||||
new_model = "claude-3"
|
||||
|
|
@ -129,9 +145,9 @@ def test_should_replace_model_in_jsonl():
|
|||
"""Test that should_replace_model_in_jsonl returns the correct value"""
|
||||
from litellm.router_utils.batch_utils import should_replace_model_in_jsonl
|
||||
|
||||
assert should_replace_model_in_jsonl(purpose="batch") == True
|
||||
assert should_replace_model_in_jsonl(purpose="test") == False
|
||||
assert should_replace_model_in_jsonl(purpose="user_data") == False
|
||||
assert should_replace_model_in_jsonl(purpose="batch") is True
|
||||
assert should_replace_model_in_jsonl(purpose="test") is False
|
||||
assert should_replace_model_in_jsonl(purpose="user_data") is False
|
||||
|
||||
|
||||
def test_parse_jsonl_with_embedded_newlines_simple():
|
||||
|
|
@ -217,6 +233,63 @@ def test_parse_jsonl_with_embedded_newlines_whitespace_only():
|
|||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_replace_model_in_jsonl_malformed_middle_row_returns_original():
|
||||
"""Regression: a malformed/truncated middle row must not silently drop the
|
||||
rows that follow it. The streaming rewrite accumulates physical lines into a
|
||||
buffer; a row that never parses poisons the buffer so every later valid row
|
||||
is concatenated into it and dropped. Returning that partial rewrite would
|
||||
ship a truncated batch with no error to the caller. Instead the original
|
||||
content is returned unchanged so the provider rejects the bad batch loudly."""
|
||||
content = (
|
||||
b'{"custom_id":"a","body":{"model":"x"}}\n'
|
||||
b'{"custom_id":"b","body":{"model":\n' # truncated, never completes
|
||||
b'{"custom_id":"c","body":{"model":"x"}}\n'
|
||||
)
|
||||
|
||||
result = replace_model_in_jsonl(content, "new-model")
|
||||
|
||||
assert (
|
||||
result == content
|
||||
), "must return the original unchanged, not a partial rewrite"
|
||||
|
||||
|
||||
def test_replace_model_in_jsonl_malformed_row_seekable_handle_rewound():
|
||||
"""When the source is a seekable handle that gets consumed during the failed
|
||||
rewrite, it must be rewound to 0 so the caller can re-read the full original."""
|
||||
content = (
|
||||
b'{"custom_id":"a","body":{"model":"x"}}\n'
|
||||
b'{"custom_id":"b","body":{"model":\n'
|
||||
b'{"custom_id":"c","body":{"model":"x"}}\n'
|
||||
)
|
||||
handle = BytesIO(content)
|
||||
|
||||
result = replace_model_in_jsonl(handle, "new-model")
|
||||
|
||||
assert result is handle
|
||||
assert handle.read() == content, "handle must be rewound for the caller to re-read"
|
||||
|
||||
|
||||
def test_replace_model_in_jsonl_multi_row_rewrites_every_model():
|
||||
"""Happy path: a well-formed multi-row file gets every row's model rewritten
|
||||
and no row is dropped."""
|
||||
content = (
|
||||
b'{"custom_id":"a","body":{"model":"old1"}}\n'
|
||||
b'{"custom_id":"b","body":{"model":"old2"}}\n'
|
||||
b'{"custom_id":"c","body":{"model":"old3"}}\n'
|
||||
)
|
||||
|
||||
result = replace_model_in_jsonl(content, "new-model")
|
||||
|
||||
assert isinstance(result, InMemoryFile)
|
||||
rows = [
|
||||
json.loads(line)
|
||||
for line in result.getvalue().decode("utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
assert [row["custom_id"] for row in rows] == ["a", "b", "c"]
|
||||
assert all(row["body"]["model"] == "new-model" for row in rows)
|
||||
|
||||
|
||||
def test_replace_model_in_jsonl_with_embedded_newlines():
|
||||
"""Test that replace_model_in_jsonl works correctly with embedded newlines in content"""
|
||||
# Create a JSONL with embedded newlines in the message content
|
||||
|
|
|
|||
|
|
@ -3155,3 +3155,46 @@ def test_handle_anthropic_messages_response_logging_with_terminal_responses_api_
|
|||
result = logging_obj._handle_anthropic_messages_response_logging(result=event)
|
||||
|
||||
assert result is inner_response
|
||||
|
||||
|
||||
def test_failure_handler_records_recovered_partial_spend(logging_obj):
|
||||
"""A stream interrupted mid-flight still billed the provider for the chunks
|
||||
already delivered. When the router stashes that recovered usage as
|
||||
``combined_usage_object`` and pre-computes ``response_cost``, the failure
|
||||
handler must preserve them so the failure row carries the real partial
|
||||
spend instead of zero.
|
||||
"""
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
logging_obj.model_call_details["combined_usage_object"] = Usage(
|
||||
prompt_tokens=17, completion_tokens=9, total_tokens=26
|
||||
)
|
||||
logging_obj.model_call_details["response_cost"] = 0.00012
|
||||
|
||||
logging_obj._failure_handler_helper_fn(
|
||||
exception=Exception("Connection lost"),
|
||||
traceback_exception="Traceback ...",
|
||||
)
|
||||
|
||||
payload = logging_obj.model_call_details["standard_logging_object"]
|
||||
assert payload["status"] == "failure"
|
||||
assert payload["response_cost"] == 0.00012
|
||||
assert payload["prompt_tokens"] == 17
|
||||
assert payload["completion_tokens"] == 9
|
||||
assert payload["total_tokens"] == 26
|
||||
|
||||
|
||||
def test_failure_handler_zeroes_spend_without_recovered_usage(logging_obj):
|
||||
"""A failure with no recovered partial usage keeps the existing behavior of
|
||||
recording zero spend, so the partial-spend preservation does not leak into
|
||||
ordinary failures.
|
||||
"""
|
||||
logging_obj._failure_handler_helper_fn(
|
||||
exception=Exception("boom"),
|
||||
traceback_exception="Traceback ...",
|
||||
)
|
||||
|
||||
payload = logging_obj.model_call_details["standard_logging_object"]
|
||||
assert payload["status"] == "failure"
|
||||
assert payload["response_cost"] == 0
|
||||
assert payload["total_tokens"] == 0
|
||||
|
|
|
|||
|
|
@ -2268,7 +2268,9 @@ def test_chunk_creator_tool_calls_not_dropped_on_finish(
|
|||
tool_calls=[
|
||||
ChatCompletionDeltaToolCall(
|
||||
id="call_abc",
|
||||
function=Function(name="get_weather", arguments='{"city":"NYC"}'),
|
||||
function=Function(
|
||||
name="get_weather", arguments='{"city":"NYC"}'
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
|
|
@ -2287,3 +2289,207 @@ def test_chunk_creator_tool_calls_not_dropped_on_finish(
|
|||
assert result.choices[0].delta.tool_calls is not None
|
||||
assert result.choices[0].finish_reason is None
|
||||
assert initialized_custom_stream_wrapper.received_finish_reason == "tool_calls"
|
||||
|
||||
|
||||
def test_record_partial_usage_for_failure_stashes_usage_and_cost():
|
||||
"""A stream that breaks mid-flight must surface the usage assembled from the
|
||||
chunks already delivered, plus its cost, on the logging object so the
|
||||
failure handler records the real partial spend instead of zero.
|
||||
"""
|
||||
logging_obj = Logging(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hey"}],
|
||||
stream=True,
|
||||
call_type="completion",
|
||||
start_time=time.time(),
|
||||
litellm_call_id="partial-usage-1",
|
||||
function_id="1245",
|
||||
)
|
||||
logging_obj.model_call_details["custom_llm_provider"] = "openai"
|
||||
|
||||
wrapper = CustomStreamWrapper(
|
||||
completion_stream=None,
|
||||
model="gpt-4o-mini",
|
||||
logging_obj=logging_obj,
|
||||
custom_llm_provider="openai",
|
||||
)
|
||||
wrapper.chunks = [
|
||||
ModelResponseStream(
|
||||
id="chatcmpl-partial-1",
|
||||
created=1742056047,
|
||||
model="gpt-4o-mini",
|
||||
object="chat.completion.chunk",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
finish_reason=None,
|
||||
index=0,
|
||||
delta=Delta(
|
||||
content="The Roman Empire began when", role="assistant"
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=Usage(prompt_tokens=30, completion_tokens=1, total_tokens=31),
|
||||
)
|
||||
]
|
||||
|
||||
wrapper._record_partial_usage_for_failure()
|
||||
|
||||
stashed = logging_obj.model_call_details["combined_usage_object"]
|
||||
assert stashed.prompt_tokens == 30
|
||||
assert stashed.completion_tokens == 1
|
||||
assert stashed.total_tokens == 31
|
||||
assert isinstance(logging_obj.model_call_details["response_cost"], float)
|
||||
|
||||
|
||||
def test_record_partial_usage_for_failure_noop_without_chunks():
|
||||
"""With no chunks delivered there is nothing billed to recover, so the
|
||||
failure stash must stay absent and not force a zero-usage row.
|
||||
"""
|
||||
logging_obj = Logging(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hey"}],
|
||||
stream=True,
|
||||
call_type="completion",
|
||||
start_time=time.time(),
|
||||
litellm_call_id="partial-usage-2",
|
||||
function_id="1245",
|
||||
)
|
||||
wrapper = CustomStreamWrapper(
|
||||
completion_stream=None,
|
||||
model="gpt-4o-mini",
|
||||
logging_obj=logging_obj,
|
||||
custom_llm_provider="openai",
|
||||
)
|
||||
wrapper.chunks = []
|
||||
|
||||
wrapper._record_partial_usage_for_failure()
|
||||
|
||||
assert "combined_usage_object" not in logging_obj.model_call_details
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sync_mode", [True, False])
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_chunk_builder_raise_at_end_of_stream_still_recovers_usage(
|
||||
sync_mode,
|
||||
):
|
||||
"""stream_chunk_builder re-raises (as APIError) on large agentic tool-use
|
||||
streams. That raise originates inside the except-StopIteration handler, so
|
||||
before the fix it escaped __next__/__anext__ and the request was dropped from
|
||||
SpendLogs while the provider billed the tokens. The wrapper must catch it and
|
||||
recover usage from the raw chunks so cost is still tracked."""
|
||||
final_usage_block = Usage(
|
||||
completion_tokens=392, prompt_tokens=1799, total_tokens=2191
|
||||
)
|
||||
final_chunk = ModelResponseStream(
|
||||
id="chatcmpl-raise-test",
|
||||
created=1742056047,
|
||||
model=None,
|
||||
object="chat.completion.chunk",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
delta=Delta(content="", role="assistant"),
|
||||
)
|
||||
],
|
||||
usage=final_usage_block,
|
||||
)
|
||||
test_chunks = bedrock_chunks + [final_chunk]
|
||||
|
||||
logging_obj = Logging(
|
||||
model="bedrock/claude-haiku-4-5-20251001-v1:0",
|
||||
messages=[{"role": "user", "content": "Hey"}],
|
||||
stream=True,
|
||||
call_type="completion",
|
||||
start_time=time.time(),
|
||||
litellm_call_id="raise-test",
|
||||
function_id="1245",
|
||||
)
|
||||
|
||||
response = CustomStreamWrapper(
|
||||
completion_stream=ModelResponseListIterator(model_responses=test_chunks),
|
||||
model="bedrock/claude-haiku-4-5-20251001-v1:0",
|
||||
custom_llm_provider="bedrock",
|
||||
logging_obj=logging_obj,
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
|
||||
seen_usage = []
|
||||
with patch.object(
|
||||
litellm,
|
||||
"stream_chunk_builder",
|
||||
side_effect=Exception("simulated assembly failure"),
|
||||
):
|
||||
# before the fix this raised and dropped the request; it must not raise now
|
||||
if sync_mode:
|
||||
for chunk in response:
|
||||
if getattr(chunk, "usage", None) is not None:
|
||||
seen_usage.append(chunk.usage)
|
||||
else:
|
||||
async for chunk in response:
|
||||
if getattr(chunk, "usage", None) is not None:
|
||||
seen_usage.append(chunk.usage)
|
||||
|
||||
assert any(
|
||||
u.total_tokens == final_usage_block.total_tokens for u in seen_usage
|
||||
), "usage recovered from raw chunks was not emitted after stream_chunk_builder raised"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sync_mode", [True, False])
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_chunk_builder_raise_and_usage_recovery_failure_does_not_crash(
|
||||
sync_mode,
|
||||
):
|
||||
"""If end-of-stream assembly raises AND best-effort usage recovery from the raw
|
||||
chunks also fails, the stream must still complete cleanly rather than propagate
|
||||
the exception to the consumer."""
|
||||
from litellm.litellm_core_utils import streaming_handler as sh_module
|
||||
|
||||
final_chunk = ModelResponseStream(
|
||||
id="chatcmpl-raise-recover-fail",
|
||||
created=1742056047,
|
||||
model=None,
|
||||
object="chat.completion.chunk",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
delta=Delta(content="", role="assistant"),
|
||||
)
|
||||
],
|
||||
usage=Usage(completion_tokens=1, prompt_tokens=1, total_tokens=2),
|
||||
)
|
||||
|
||||
response = CustomStreamWrapper(
|
||||
completion_stream=ModelResponseListIterator(
|
||||
model_responses=bedrock_chunks + [final_chunk]
|
||||
),
|
||||
model="bedrock/claude-haiku-4-5-20251001-v1:0",
|
||||
custom_llm_provider="bedrock",
|
||||
logging_obj=Logging(
|
||||
model="bedrock/claude-haiku-4-5-20251001-v1:0",
|
||||
messages=[{"role": "user", "content": "Hey"}],
|
||||
stream=True,
|
||||
call_type="completion",
|
||||
start_time=time.time(),
|
||||
litellm_call_id="raise-recover-fail",
|
||||
function_id="1245",
|
||||
),
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
litellm, "stream_chunk_builder", side_effect=Exception("assembly failed")
|
||||
),
|
||||
patch.object(
|
||||
sh_module, "calculate_total_usage", side_effect=Exception("recovery failed")
|
||||
),
|
||||
):
|
||||
# must not raise even though both assembly and recovery fail
|
||||
if sync_mode:
|
||||
chunks = [c for c in response]
|
||||
else:
|
||||
chunks = [c async for c in response]
|
||||
|
||||
assert len(chunks) > 0
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ Regression test for: UTF-8 codec error when uploading binary files
|
|||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -137,11 +137,11 @@ class TestVertexAIBinaryFileUpload:
|
|||
), "Binary file data should remain as bytes"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jsonl_file_upload_returns_string(self):
|
||||
async def test_jsonl_file_upload_returns_resumable_stream(self):
|
||||
"""
|
||||
Test that JSONL files (text) are correctly transformed to strings.
|
||||
|
||||
This ensures we handle both binary and text files correctly.
|
||||
Test that JSONL batch files are transformed into a resumable-upload config
|
||||
carrying a streaming body (not a buffered bytes payload), so the handler
|
||||
can stream the upload to GCS in bounded chunks.
|
||||
"""
|
||||
# Create mock JSONL content
|
||||
mock_jsonl_content = (
|
||||
|
|
@ -164,10 +164,16 @@ class TestVertexAIBinaryFileUpload:
|
|||
litellm_params={},
|
||||
)
|
||||
|
||||
# JSONL files should be transformed to string
|
||||
assert isinstance(
|
||||
transformed_request, str
|
||||
), f"Expected string for JSONL file, got {type(transformed_request)}"
|
||||
assert (
|
||||
isinstance(transformed_request, dict)
|
||||
and "resumable_chunked_upload" in transformed_request
|
||||
), f"Expected a resumable upload config for JSONL, got {type(transformed_request)}"
|
||||
|
||||
stream = transformed_request["resumable_chunked_upload"]["body_stream"]
|
||||
decoded = json.loads(b"".join(stream.iter_bytes()).decode("utf-8"))
|
||||
assert (
|
||||
"request" in decoded
|
||||
), "JSONL transform must wrap each row in {'request': ...}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_file_types_in_sequence(self):
|
||||
|
|
@ -208,7 +214,7 @@ class TestVertexAIBinaryFileUpload:
|
|||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
assert isinstance(result2, str)
|
||||
assert isinstance(result2, dict) and "resumable_chunked_upload" in result2
|
||||
|
||||
# Test 3: Upload another binary file
|
||||
binary_content2 = b"\xc4\xe5\xf2\xe5\xeb"
|
||||
|
|
@ -251,7 +257,7 @@ class TestVertexAIBinaryFileUpload:
|
|||
},
|
||||
"text_files": {
|
||||
"input_type": "str or bytes",
|
||||
"output_type": "str",
|
||||
"output_type": "bytes",
|
||||
"examples": ["JSONL", "CSV", "TXT"],
|
||||
"http_method": "POST",
|
||||
"encoding": "UTF-8",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,696 @@
|
|||
"""
|
||||
Tests for the streaming OpenAI -> Vertex JSONL batch transform.
|
||||
|
||||
The transform converts batch uploads entry-by-entry rather than materializing
|
||||
the payload in full intermediate lists (decoded str, parsed dicts, transformed
|
||||
dicts, joined output), which keeps peak memory bounded on large uploads.
|
||||
|
||||
These tests lock in the behaviour that would regress if the streaming path were
|
||||
replaced by a list-based pipeline:
|
||||
1. Byte-for-byte output parity with a list pipeline (wire format).
|
||||
2. The streaming transform peaks at a clear fraction of a list pipeline on the
|
||||
same input (relative differential, robust to GC noise).
|
||||
3. ``get_object_name`` only parses the first JSONL row, so a payload whose
|
||||
later rows are not valid JSON does not raise.
|
||||
4. A tuple-wrapped file handle uploaded through the real create_file ordering
|
||||
keeps every row, including entry 0 (no partial upload from a consumed
|
||||
cursor).
|
||||
"""
|
||||
|
||||
import gc
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
import tracemalloc
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.llms.base_llm.files.transformation import BaseFileUploadStream
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
|
||||
from litellm.llms.vertex_ai.files.transformation import (
|
||||
VertexAIFilesConfig,
|
||||
_OpenAIToVertexBatchUploadStream,
|
||||
_get_litellm_batch_custom_id_from_labels,
|
||||
_iter_openai_jsonl_entries,
|
||||
_iter_openai_jsonl_lines,
|
||||
_openai_batch_jsonl_entry_to_vertex_wrapped_request,
|
||||
)
|
||||
from litellm.types.llms.openai import CreateFileRequest
|
||||
|
||||
|
||||
def _resumable_stream(transformed) -> BaseFileUploadStream:
|
||||
"""Pull the streaming body out of a resumable-upload transform result."""
|
||||
return transformed["resumable_chunked_upload"]["body_stream"]
|
||||
|
||||
|
||||
def _join_upload_body(transformed) -> bytes:
|
||||
"""Materialize a transform result's upload body for byte-level assertions."""
|
||||
if isinstance(transformed, dict) and "resumable_chunked_upload" in transformed:
|
||||
return b"".join(_resumable_stream(transformed).iter_bytes())
|
||||
if isinstance(transformed, BaseFileUploadStream):
|
||||
return b"".join(transformed.iter_bytes())
|
||||
if isinstance(transformed, str):
|
||||
return transformed.encode("utf-8")
|
||||
return transformed
|
||||
|
||||
|
||||
def _make_openai_jsonl_bytes(n_rows: int, padding: int = 400) -> bytes:
|
||||
pad = "x" * padding
|
||||
rows = []
|
||||
for i in range(n_rows):
|
||||
rows.append(
|
||||
json.dumps(
|
||||
{
|
||||
"custom_id": f"request-{i}",
|
||||
"method": "POST",
|
||||
"url": "/v1/chat/completions",
|
||||
"body": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"messages": [{"role": "user", "content": f"{pad} {i}"}],
|
||||
"max_tokens": 4,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return ("\n".join(rows)).encode("utf-8")
|
||||
|
||||
|
||||
def _reference_vertex_jsonl_string(cfg: VertexAIFilesConfig, content: str) -> str:
|
||||
"""Row-by-row reference output built eagerly from the live single-entry
|
||||
transform, so the streaming path can be checked against it for parity."""
|
||||
entries = [json.loads(line) for line in content.splitlines() if line.strip()]
|
||||
return "\n".join(
|
||||
json.dumps(
|
||||
_openai_batch_jsonl_entry_to_vertex_wrapped_request(
|
||||
entry, cfg._map_openai_to_vertex_params
|
||||
)
|
||||
)
|
||||
for entry in entries
|
||||
)
|
||||
|
||||
|
||||
class TestStreamingOutputParity:
|
||||
def test_transform_create_file_request_returns_resumable_stream_parity(self):
|
||||
cfg = VertexAIFilesConfig()
|
||||
raw = _make_openai_jsonl_bytes(300)
|
||||
request: CreateFileRequest = {
|
||||
"file": ("batch.jsonl", raw, "application/jsonl"),
|
||||
"purpose": "batch",
|
||||
}
|
||||
|
||||
out = cfg.transform_create_file_request(
|
||||
model="", create_file_data=request, optional_params={}, litellm_params={}
|
||||
)
|
||||
|
||||
# A batch upload must be a resumable-upload config carrying a streaming
|
||||
# body, so the handler can chunk it; a buffered bytes/str return would
|
||||
# defeat the OOM fix.
|
||||
assert isinstance(out, dict) and "resumable_chunked_upload" in out
|
||||
assert isinstance(_resumable_stream(out), BaseFileUploadStream)
|
||||
assert _join_upload_body(out).decode("utf-8") == _reference_vertex_jsonl_string(
|
||||
cfg, raw.decode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
class TestFileLikeInputNotPartiallyConsumed:
|
||||
"""
|
||||
In ``llm_http_handler.create_file`` the object-name step
|
||||
(get_complete_file_url -> get_object_name) runs before
|
||||
transform_create_file_request, and both read the same create_file_data
|
||||
source. When the file is a tuple-wrapped open handle, the streaming reader
|
||||
must still emit every row including entry 0: ``_iter_openai_jsonl_lines``
|
||||
rewinds a seekable source (seek(0)) before each pass, so the object-name
|
||||
step's partial read of the cursor does not consume the upload. A partial
|
||||
upload missing the first request would be silent and hard to catch, so this
|
||||
locks the full-payload invariant in.
|
||||
"""
|
||||
|
||||
def test_filehandle_create_file_keeps_first_entry(self):
|
||||
cfg = VertexAIFilesConfig()
|
||||
n_rows = 25
|
||||
raw = _make_openai_jsonl_bytes(n_rows)
|
||||
create_file_data: CreateFileRequest = {
|
||||
"file": ("batch.jsonl", io.BytesIO(raw), "application/jsonl"),
|
||||
"purpose": "batch",
|
||||
}
|
||||
|
||||
# Object-name step first (as the handler does), then the transform, both
|
||||
# reading the same live BytesIO handle.
|
||||
cfg.get_complete_file_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="",
|
||||
optional_params={},
|
||||
litellm_params={"gcs_bucket_name": "test-bucket"},
|
||||
data=create_file_data,
|
||||
)
|
||||
out = cfg.transform_create_file_request(
|
||||
model="",
|
||||
create_file_data=create_file_data,
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
lines = _join_upload_body(out).decode("utf-8").splitlines()
|
||||
assert len(lines) == n_rows, "no batch row may be dropped from the upload"
|
||||
first_labels = json.loads(lines[0])["request"]["labels"]
|
||||
assert _get_litellm_batch_custom_id_from_labels(first_labels) == "request-0"
|
||||
|
||||
|
||||
class TestStreamingLineIterator:
|
||||
def test_skips_blank_and_whitespace_lines(self):
|
||||
content = b'{"a": 1}\n\n \n{"b": 2}\n'
|
||||
assert list(_iter_openai_jsonl_lines(content)) == ['{"a": 1}', '{"b": 2}']
|
||||
|
||||
def test_handles_crlf_and_missing_trailing_newline(self):
|
||||
content = b'{"a": 1}\r\n{"b": 2}'
|
||||
assert [json.loads(line) for line in _iter_openai_jsonl_lines(content)] == [
|
||||
{"a": 1},
|
||||
{"b": 2},
|
||||
]
|
||||
|
||||
def test_accepts_str_bytes_tuple_and_filelike(self):
|
||||
expected = [{"a": 1}, {"b": 2}]
|
||||
text = '{"a": 1}\n{"b": 2}\n'
|
||||
for source in (
|
||||
text,
|
||||
text.encode("utf-8"),
|
||||
("name.jsonl", text.encode("utf-8"), "application/jsonl"),
|
||||
io.BytesIO(text.encode("utf-8")),
|
||||
):
|
||||
assert list(_iter_openai_jsonl_entries(source)) == expected
|
||||
|
||||
def test_str_input_without_trailing_newline(self):
|
||||
assert list(_iter_openai_jsonl_lines('{"a": 1}\n{"b": 2}')) == [
|
||||
'{"a": 1}',
|
||||
'{"b": 2}',
|
||||
]
|
||||
|
||||
def test_pathlike_input_is_read_line_by_line(self, tmp_path):
|
||||
path = tmp_path / "batch.jsonl"
|
||||
path.write_bytes(b'{"a": 1}\n{"b": 2}\n')
|
||||
assert list(_iter_openai_jsonl_entries(path)) == [{"a": 1}, {"b": 2}]
|
||||
|
||||
def test_unsupported_content_type_raises(self):
|
||||
with pytest.raises(ValueError, match="Unsupported file content type"):
|
||||
list(_iter_openai_jsonl_lines(12345)) # type: ignore[arg-type]
|
||||
|
||||
def test_non_seekable_handle_raises_instead_of_dropping_first_row(self):
|
||||
# The handle is read twice (object-name probe, then body). A non-seekable
|
||||
# handle can't rewind, so it must fail loudly rather than silently resume
|
||||
# mid-stream and omit the opening batch request.
|
||||
class _NonSeekable:
|
||||
def __init__(self, raw: bytes):
|
||||
self._buf = io.BytesIO(raw)
|
||||
|
||||
def read(self, *args):
|
||||
return self._buf.read(*args)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._buf)
|
||||
|
||||
def seek(self, *args):
|
||||
raise io.UnsupportedOperation("not seekable")
|
||||
|
||||
handle = _NonSeekable(
|
||||
b'{"custom_id": "request-0"}\n{"custom_id": "request-1"}\n'
|
||||
)
|
||||
with pytest.raises(ValueError, match="seekable"):
|
||||
list(_iter_openai_jsonl_lines(handle))
|
||||
|
||||
def test_is_lazy_does_not_parse_past_first_entry(self):
|
||||
# Second row is invalid JSON; pulling only the first entry must not raise.
|
||||
content = b'{"custom_id": "first"}\nnot-json-at-all\n'
|
||||
gen = _iter_openai_jsonl_entries(content)
|
||||
assert next(gen)["custom_id"] == "first"
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
next(gen)
|
||||
|
||||
|
||||
class TestGetObjectNameLazyParse:
|
||||
def test_only_parses_first_row_for_model(self):
|
||||
cfg = VertexAIFilesConfig()
|
||||
# Tail rows are deliberately not valid JSON. Parsing the whole payload
|
||||
# would raise here; a first-row-only parse must not.
|
||||
raw = (
|
||||
b'{"custom_id": "r-0", "body": {"model": "gemini-2.5-flash"}}\n'
|
||||
b"garbage line that is not json\n"
|
||||
)
|
||||
object_name = cfg.get_object_name(
|
||||
("batch.jsonl", raw, "application/jsonl"), purpose="batch"
|
||||
)
|
||||
assert "gemini-2.5-flash" in object_name
|
||||
|
||||
|
||||
class TestStreamingPeakMemory:
|
||||
"""
|
||||
Differential guard: the streaming transform must stay well under the peak
|
||||
that a list pipeline incurs on the same input. If the hot path builds full
|
||||
intermediate lists, the streaming assertion fails.
|
||||
|
||||
The assertion that matters is the *relative* one: ``streaming_peak`` must be
|
||||
a clear fraction of ``list_peak`` on the identical input. Absolute
|
||||
``tracemalloc`` ratios drift with GC timing and the live set carried in from
|
||||
earlier tests, so they make poor CI gates; the relative comparison cancels
|
||||
that shared noise and is exactly what regresses (toward 1.0) when the hot
|
||||
path builds full intermediate lists. ``gc.collect()`` before each
|
||||
measurement removes any garbage the previous run left behind.
|
||||
"""
|
||||
|
||||
def _measure(self, fn):
|
||||
gc.collect()
|
||||
tracemalloc.start()
|
||||
try:
|
||||
fn()
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
return peak
|
||||
|
||||
def test_streaming_peak_well_below_list_pipeline(self):
|
||||
cfg = VertexAIFilesConfig()
|
||||
raw = _make_openai_jsonl_bytes(8000)
|
||||
content_str = raw.decode("utf-8")
|
||||
|
||||
def drain_stream():
|
||||
# Consume the upload body one row at a time, as the chunked uploader
|
||||
# does, without accumulating it.
|
||||
for _ in _OpenAIToVertexBatchUploadStream(
|
||||
raw, cfg._map_openai_to_vertex_params
|
||||
).iter_bytes():
|
||||
pass
|
||||
|
||||
streaming_peak = self._measure(drain_stream)
|
||||
list_peak = self._measure(
|
||||
lambda: _reference_vertex_jsonl_string(cfg, content_str)
|
||||
)
|
||||
|
||||
# Core guard: the lazily consumed streaming body peaks well under a list
|
||||
# pipeline that materializes every transformed row. Building full
|
||||
# intermediate lists in the hot path pushes this ratio back toward 1.0.
|
||||
assert streaming_peak < list_peak * 0.6, (
|
||||
f"streaming peak {streaming_peak} not a clear win over list pipeline "
|
||||
f"{list_peak} (ratio {streaming_peak / list_peak:.2f})"
|
||||
)
|
||||
|
||||
def test_get_object_name_does_not_scale_with_payload(self):
|
||||
cfg = VertexAIFilesConfig()
|
||||
raw = _make_openai_jsonl_bytes(8000)
|
||||
file_data = ("batch.jsonl", raw, "application/jsonl")
|
||||
|
||||
# The payload bytes already exist before measurement starts, so a lazy
|
||||
# first-row parse should allocate only a small fraction of the payload;
|
||||
# parsing every row would blow past this bound.
|
||||
peak = self._measure(lambda: cfg.get_object_name(file_data, purpose="batch"))
|
||||
assert (
|
||||
peak / len(raw) < 2.0
|
||||
), "get_object_name should not copy the whole payload"
|
||||
|
||||
|
||||
class TestPathSourcedStreaming:
|
||||
"""
|
||||
The proxy spools large batch uploads to a temp file and passes a pathlib.Path
|
||||
as the file content instead of pre-reading bytes, so the transform streams
|
||||
from disk. These lock in that a Path source yields identical output, keeps
|
||||
every row, stays memory-bounded, and is re-iterable (multi-model uploads).
|
||||
"""
|
||||
|
||||
def _write_jsonl(self, tmp_path, n_rows, padding=400):
|
||||
raw = _make_openai_jsonl_bytes(n_rows, padding=padding)
|
||||
path = tmp_path / "batch.jsonl"
|
||||
path.write_bytes(raw)
|
||||
return path, raw
|
||||
|
||||
def _batch_request(self, path) -> CreateFileRequest:
|
||||
return {"file": ("batch.jsonl", path, "application/jsonl"), "purpose": "batch"}
|
||||
|
||||
def test_transform_from_path_matches_legacy_and_keeps_all_rows(self, tmp_path):
|
||||
cfg = VertexAIFilesConfig()
|
||||
n_rows = 200
|
||||
path, raw = self._write_jsonl(tmp_path, n_rows)
|
||||
data = self._batch_request(path)
|
||||
|
||||
url = cfg.get_complete_file_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="",
|
||||
optional_params={},
|
||||
litellm_params={"gcs_bucket_name": "test-bucket"},
|
||||
data=data,
|
||||
)
|
||||
assert "uploadType=resumable" in url
|
||||
|
||||
out = cfg.transform_create_file_request(
|
||||
model="", create_file_data=data, optional_params={}, litellm_params={}
|
||||
)
|
||||
assert isinstance(out, dict) and "resumable_chunked_upload" in out
|
||||
body = _join_upload_body(out).decode("utf-8")
|
||||
assert body == _reference_vertex_jsonl_string(cfg, raw.decode("utf-8"))
|
||||
lines = body.splitlines()
|
||||
assert len(lines) == n_rows, "no batch row may be dropped from a Path source"
|
||||
first_labels = json.loads(lines[0])["request"]["labels"]
|
||||
assert _get_litellm_batch_custom_id_from_labels(first_labels) == "request-0"
|
||||
|
||||
def test_path_source_peak_stays_below_payload(self, tmp_path):
|
||||
cfg = VertexAIFilesConfig()
|
||||
path, raw = self._write_jsonl(tmp_path, 8000)
|
||||
data = self._batch_request(path)
|
||||
|
||||
def run():
|
||||
cfg.get_complete_file_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="",
|
||||
optional_params={},
|
||||
litellm_params={"gcs_bucket_name": "test-bucket"},
|
||||
data=data,
|
||||
)
|
||||
out = cfg.transform_create_file_request(
|
||||
model="", create_file_data=data, optional_params={}, litellm_params={}
|
||||
)
|
||||
for _ in _resumable_stream(out).iter_bytes():
|
||||
pass # drain without accumulating
|
||||
|
||||
gc.collect()
|
||||
tracemalloc.start()
|
||||
try:
|
||||
run()
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
|
||||
# Streaming from disk must not materialize the payload. Reading the whole
|
||||
# file into bytes (the pre-fix path) would push peak past the file size.
|
||||
assert peak < len(raw) * 0.3, (
|
||||
f"peak {peak} not bounded vs payload {len(raw)} "
|
||||
f"(ratio {peak / len(raw):.2f})"
|
||||
)
|
||||
|
||||
def test_path_source_stream_is_reiterable(self, tmp_path):
|
||||
cfg = VertexAIFilesConfig()
|
||||
path, _ = self._write_jsonl(tmp_path, 50)
|
||||
data = self._batch_request(path)
|
||||
|
||||
out = cfg.transform_create_file_request(
|
||||
model="", create_file_data=data, optional_params={}, litellm_params={}
|
||||
)
|
||||
stream = _resumable_stream(out)
|
||||
first = b"".join(stream.iter_bytes())
|
||||
second = b"".join(stream.iter_bytes())
|
||||
assert first == second and len(first) > 0
|
||||
|
||||
|
||||
_GCS_OBJECT_JSON = {
|
||||
"id": "test-bucket/litellm-vertex-files/x/123",
|
||||
"name": "litellm-vertex-files/x",
|
||||
"size": "0",
|
||||
"timeCreated": "2026-01-01T00:00:00.000000Z",
|
||||
"purpose": "batch",
|
||||
}
|
||||
|
||||
|
||||
class _FixedBytesStream(BaseFileUploadStream):
|
||||
"""Streaming body of exact, controllable bytes for protocol-edge tests."""
|
||||
|
||||
def __init__(self, data: bytes, piece: int = 64):
|
||||
self._data = data
|
||||
self._piece = piece
|
||||
|
||||
def iter_bytes(self):
|
||||
for i in range(0, len(self._data), self._piece):
|
||||
yield self._data[i : i + self._piece]
|
||||
|
||||
|
||||
def _logging_obj() -> Logging:
|
||||
return Logging(
|
||||
model="",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="acreate_file",
|
||||
start_time=time.time(),
|
||||
litellm_call_id="test",
|
||||
function_id="",
|
||||
)
|
||||
|
||||
|
||||
def _gcs_resumable_mock(session_url: str, final_status: int = 200):
|
||||
"""A fake GCS resumable endpoint: POST opens a session (URI in Location),
|
||||
each PUT appends and returns 308 until the final chunk returns 200/201."""
|
||||
state = {"received": bytearray(), "ranges": [], "methods": [], "urls": []}
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
state["methods"].append(request.method)
|
||||
state["urls"].append(str(request.url))
|
||||
if request.method == "POST":
|
||||
return httpx.Response(200, headers={"location": session_url})
|
||||
body = await request.aread()
|
||||
content_range = request.headers["content-range"]
|
||||
state["ranges"].append(content_range)
|
||||
state["received"].extend(body)
|
||||
if content_range.rsplit("/", 1)[-1] == "*":
|
||||
return httpx.Response(
|
||||
308, headers={"range": f"bytes=0-{len(state['received']) - 1}"}
|
||||
)
|
||||
return httpx.Response(final_status, json=_GCS_OBJECT_JSON)
|
||||
|
||||
return handler, state
|
||||
|
||||
|
||||
def _async_handler_with(mock) -> AsyncHTTPHandler:
|
||||
handler = AsyncHTTPHandler()
|
||||
handler.client = httpx.AsyncClient(transport=httpx.MockTransport(mock))
|
||||
return handler
|
||||
|
||||
|
||||
class TestResumableUploadUrl:
|
||||
def test_batch_jsonl_uses_resumable_upload_type(self):
|
||||
cfg = VertexAIFilesConfig()
|
||||
request: CreateFileRequest = {
|
||||
"file": ("batch.jsonl", _make_openai_jsonl_bytes(3), "application/jsonl"),
|
||||
"purpose": "batch",
|
||||
}
|
||||
url = cfg.get_complete_file_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="",
|
||||
optional_params={},
|
||||
litellm_params={"gcs_bucket_name": "test-bucket"},
|
||||
data=request,
|
||||
)
|
||||
assert "uploadType=resumable" in url
|
||||
assert "uploadType=media" not in url
|
||||
|
||||
def test_batch_text_plain_uses_resumable_upload_type(self):
|
||||
# Clients often label a .jsonl batch upload as text/plain; it must still
|
||||
# take the streaming/resumable path, not the buffered media path.
|
||||
cfg = VertexAIFilesConfig()
|
||||
request: CreateFileRequest = {
|
||||
"file": ("batch.jsonl", _make_openai_jsonl_bytes(3), "text/plain"),
|
||||
"purpose": "batch",
|
||||
}
|
||||
url = cfg.get_complete_file_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="",
|
||||
optional_params={},
|
||||
litellm_params={"gcs_bucket_name": "test-bucket"},
|
||||
data=request,
|
||||
)
|
||||
assert "uploadType=resumable" in url
|
||||
assert "uploadType=media" not in url
|
||||
|
||||
def test_binary_upload_stays_simple_media(self):
|
||||
cfg = VertexAIFilesConfig()
|
||||
request: CreateFileRequest = {
|
||||
"file": ("doc.pdf", b"%PDF-1.4 binary", "application/pdf"),
|
||||
"purpose": "user_data",
|
||||
}
|
||||
url = cfg.get_complete_file_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="",
|
||||
optional_params={},
|
||||
litellm_params={"gcs_bucket_name": "test-bucket"},
|
||||
data=request,
|
||||
)
|
||||
assert "uploadType=media" in url
|
||||
assert "uploadType=resumable" not in url
|
||||
|
||||
|
||||
class TestResumableStreamBody:
|
||||
def test_stream_matches_legacy_pipeline(self):
|
||||
cfg = VertexAIFilesConfig()
|
||||
raw = _make_openai_jsonl_bytes(120)
|
||||
stream = _OpenAIToVertexBatchUploadStream(raw, cfg._map_openai_to_vertex_params)
|
||||
assert b"".join(stream.iter_bytes()).decode(
|
||||
"utf-8"
|
||||
) == _reference_vertex_jsonl_string(cfg, raw.decode("utf-8"))
|
||||
|
||||
def test_stream_is_reiterable_for_retries(self):
|
||||
# A one-shot generator would make a transport retry upload an empty body;
|
||||
# iter_bytes() must yield the full payload every call.
|
||||
cfg = VertexAIFilesConfig()
|
||||
raw = _make_openai_jsonl_bytes(40)
|
||||
stream = _OpenAIToVertexBatchUploadStream(raw, cfg._map_openai_to_vertex_params)
|
||||
first = b"".join(stream.iter_bytes())
|
||||
second = b"".join(stream.iter_bytes())
|
||||
assert first == second and len(first) > 0
|
||||
|
||||
def test_stream_is_reiterable_for_seekable_file_like_input(self):
|
||||
# A seekable handle (BytesIO, temp file) must be rewound between calls;
|
||||
# otherwise the first iter_bytes() exhausts it and a retry would upload
|
||||
# an empty body silently.
|
||||
cfg = VertexAIFilesConfig()
|
||||
raw = _make_openai_jsonl_bytes(40)
|
||||
stream = _OpenAIToVertexBatchUploadStream(
|
||||
io.BytesIO(raw), cfg._map_openai_to_vertex_params
|
||||
)
|
||||
first = b"".join(stream.iter_bytes())
|
||||
second = b"".join(stream.iter_bytes())
|
||||
assert first == second and len(first) > 0
|
||||
|
||||
|
||||
class TestResumableChunking:
|
||||
def test_intermediate_chunks_are_exactly_chunk_size(self):
|
||||
pieces = list(BaseLLMHTTPHandler._iter_resumable_chunks(iter([b"x" * 10]), 4))
|
||||
assert pieces == [b"xxxx", b"xxxx", b"xx"]
|
||||
|
||||
def test_exact_multiple_yields_no_trailing_empty(self):
|
||||
# An exactly chunk-aligned stream yields only full chunks; the upload
|
||||
# finalizes on the last data chunk instead of an extra empty request.
|
||||
pieces = list(BaseLLMHTTPHandler._iter_resumable_chunks(iter([b"x" * 8]), 4))
|
||||
assert pieces == [b"xxxx", b"xxxx"]
|
||||
|
||||
def test_empty_stream_yields_nothing(self):
|
||||
# A 0-byte stream yields no chunks; the caller finalizes with one empty
|
||||
# request (bytes */0).
|
||||
assert list(BaseLLMHTTPHandler._iter_resumable_chunks(iter([]), 4)) == []
|
||||
|
||||
def test_default_chunk_size_is_256kib_multiple(self):
|
||||
assert BaseLLMHTTPHandler._RESUMABLE_CHUNK_SIZE % (256 * 1024) == 0
|
||||
|
||||
def test_content_range_intermediate_uses_star_total(self):
|
||||
assert (
|
||||
BaseLLMHTTPHandler._resumable_content_range(0, 4096, is_final=False)
|
||||
== "bytes 0-4095/*"
|
||||
)
|
||||
|
||||
def test_content_range_final_uses_real_total(self):
|
||||
assert (
|
||||
BaseLLMHTTPHandler._resumable_content_range(8192, 100, is_final=True)
|
||||
== "bytes 8192-8291/8292"
|
||||
)
|
||||
|
||||
def test_content_range_empty_finalize(self):
|
||||
assert (
|
||||
BaseLLMHTTPHandler._resumable_content_range(8192, 0, is_final=True)
|
||||
== "bytes */8192"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestResumableUploadProtocol:
|
||||
"""End-to-end against a faked GCS resumable endpoint. These are the tests
|
||||
that fail if the handler buffers the whole body, drops bytes, mislabels a
|
||||
Content-Range, follows the 308 instead of continuing, or skips finalize."""
|
||||
|
||||
async def _run(self, raw: bytes, chunk_size: int, final_status: int = 200):
|
||||
cfg = VertexAIFilesConfig()
|
||||
request: CreateFileRequest = {
|
||||
"file": ("batch.jsonl", raw, "application/jsonl"),
|
||||
"purpose": "batch",
|
||||
}
|
||||
api_base = cfg.get_complete_file_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="",
|
||||
optional_params={},
|
||||
litellm_params={"gcs_bucket_name": "test-bucket"},
|
||||
data=request,
|
||||
)
|
||||
transformed = cfg.transform_create_file_request(
|
||||
model="", create_file_data=request, optional_params={}, litellm_params={}
|
||||
)
|
||||
transformed["resumable_chunked_upload"]["chunk_size"] = chunk_size
|
||||
expected = _join_upload_body(transformed)
|
||||
|
||||
session_url = "https://storage.googleapis.com/upload/sess?upload_id=SID"
|
||||
mock, state = _gcs_resumable_mock(session_url, final_status=final_status)
|
||||
response = await BaseLLMHTTPHandler().async_create_file(
|
||||
transformed_request=transformed,
|
||||
litellm_params={},
|
||||
provider_config=cfg,
|
||||
headers={"Authorization": "Bearer x"},
|
||||
api_base=api_base,
|
||||
logging_obj=_logging_obj(),
|
||||
client=_async_handler_with(mock),
|
||||
timeout=None,
|
||||
)
|
||||
return expected, state, response, session_url, api_base
|
||||
|
||||
async def test_streams_in_chunks_and_reassembles(self):
|
||||
raw = _make_openai_jsonl_bytes(300)
|
||||
chunk_size = 4096
|
||||
expected, state, response, session_url, api_base = await self._run(
|
||||
raw, chunk_size
|
||||
)
|
||||
|
||||
# One session-open POST, then a sequence of chunk PUTs.
|
||||
assert state["methods"][0] == "POST"
|
||||
assert set(state["methods"][1:]) == {"PUT"}
|
||||
assert state["methods"].count("PUT") >= 2, "payload must span multiple chunks"
|
||||
|
||||
# POST opens a resumable session; every chunk goes to the session URI.
|
||||
assert "uploadType=resumable" in state["urls"][0]
|
||||
assert all(u == session_url for u in state["urls"][1:])
|
||||
|
||||
# Every non-final chunk is exactly chunk_size with an unknown-total range;
|
||||
# the final chunk carries the real total.
|
||||
intermediate = state["ranges"][:-1]
|
||||
for index, content_range in enumerate(intermediate):
|
||||
assert (
|
||||
content_range
|
||||
== f"bytes {index * chunk_size}-{(index + 1) * chunk_size - 1}/*"
|
||||
)
|
||||
total = len(expected)
|
||||
last_offset = len(intermediate) * chunk_size
|
||||
if last_offset == total: # payload landed on a chunk boundary
|
||||
assert state["ranges"][-1] == f"bytes */{total}"
|
||||
else:
|
||||
assert state["ranges"][-1] == f"bytes {last_offset}-{total - 1}/{total}"
|
||||
|
||||
# The bytes GCS received are exactly the transformed batch payload.
|
||||
assert bytes(state["received"]) == expected
|
||||
assert response.object == "file"
|
||||
|
||||
async def test_exact_multiple_finalizes_on_last_data_chunk(self):
|
||||
# A body that is an exact multiple of the chunk size finalizes on its
|
||||
# last data chunk (bytes (TOTAL-chunk)-(TOTAL-1)/TOTAL), with no extra
|
||||
# empty finalize request.
|
||||
chunk_size = 256
|
||||
total = chunk_size * 3
|
||||
stream = _FixedBytesStream(b"a" * total)
|
||||
config = {"body_stream": stream, "chunk_size": chunk_size}
|
||||
session_url = "https://storage.googleapis.com/upload/sess?upload_id=SID"
|
||||
mock, state = _gcs_resumable_mock(session_url)
|
||||
|
||||
response = await BaseLLMHTTPHandler()._aresumable_chunked_upload(
|
||||
client=_async_handler_with(mock),
|
||||
initiate_url="https://storage.googleapis.com/upload?uploadType=resumable",
|
||||
base_headers={"Authorization": "Bearer x"},
|
||||
config=config,
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
assert state["ranges"][-1] == f"bytes {total - chunk_size}-{total - 1}/{total}"
|
||||
assert "*" not in state["ranges"][-1]
|
||||
assert bytes(state["received"]) == b"a" * total
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_failed_chunk_raises(self):
|
||||
raw = _make_openai_jsonl_bytes(80)
|
||||
with pytest.raises(Exception):
|
||||
await self._run(raw, chunk_size=4096, final_status=403)
|
||||
|
|
@ -14,8 +14,8 @@ from unittest.mock import MagicMock
|
|||
|
||||
from litellm.llms.vertex_ai.files.transformation import (
|
||||
VertexAIFilesConfig,
|
||||
VertexAIJsonlFilesTransformation,
|
||||
_get_litellm_batch_custom_id_from_labels,
|
||||
_openai_batch_jsonl_entry_to_vertex_wrapped_request,
|
||||
_sanitize_gcp_label_value,
|
||||
)
|
||||
from litellm.types.llms.openai import OpenAIFileObject, HttpxBinaryResponseContent
|
||||
|
|
@ -33,7 +33,7 @@ class TestParseGcsUri:
|
|||
def test_should_parse_standard_gs_uri(self, config):
|
||||
file_id = "gs://my-bucket/litellm-vertex-files/path/to/object.jsonl"
|
||||
bucket, encoded = config._parse_gcs_uri(
|
||||
file_id, litellm_params={"bucket_name": "my-bucket"}
|
||||
file_id, litellm_params={"gcs_bucket_name": "my-bucket"}
|
||||
)
|
||||
assert bucket == "my-bucket"
|
||||
assert encoded == urllib.parse.quote(
|
||||
|
|
@ -43,7 +43,7 @@ class TestParseGcsUri:
|
|||
def test_should_parse_uri_with_nested_publisher_path(self, config):
|
||||
uri = "gs://litellm-local/litellm-vertex-files/publishers/google/models/gemini-2.0-flash-001/abc-123"
|
||||
bucket, encoded = config._parse_gcs_uri(
|
||||
uri, litellm_params={"bucket_name": "litellm-local"}
|
||||
uri, litellm_params={"gcs_bucket_name": "litellm-local"}
|
||||
)
|
||||
assert bucket == "litellm-local"
|
||||
expected_path = (
|
||||
|
|
@ -56,7 +56,7 @@ class TestParseGcsUri:
|
|||
"gs://my-bucket/litellm-vertex-files/some/path", safe=""
|
||||
)
|
||||
bucket, encoded = config._parse_gcs_uri(
|
||||
encoded_uri, litellm_params={"bucket_name": "my-bucket"}
|
||||
encoded_uri, litellm_params={"gcs_bucket_name": "my-bucket"}
|
||||
)
|
||||
assert bucket == "my-bucket"
|
||||
assert encoded == urllib.parse.quote("litellm-vertex-files/some/path", safe="")
|
||||
|
|
@ -64,21 +64,21 @@ class TestParseGcsUri:
|
|||
def test_should_reject_bucket_only(self, config):
|
||||
with pytest.raises(ValueError, match="object name"):
|
||||
config._parse_gcs_uri(
|
||||
"gs://my-bucket", litellm_params={"bucket_name": "my-bucket"}
|
||||
"gs://my-bucket", litellm_params={"gcs_bucket_name": "my-bucket"}
|
||||
)
|
||||
|
||||
def test_should_reject_no_gs_prefix(self, config):
|
||||
with pytest.raises(ValueError, match="gs://"):
|
||||
config._parse_gcs_uri(
|
||||
"my-bucket/litellm-vertex-files/object.txt",
|
||||
litellm_params={"bucket_name": "my-bucket"},
|
||||
litellm_params={"gcs_bucket_name": "my-bucket"},
|
||||
)
|
||||
|
||||
def test_should_reject_unmanaged_object_path(self, config):
|
||||
with pytest.raises(ValueError, match="LiteLLM-managed"):
|
||||
config._parse_gcs_uri(
|
||||
"gs://my-bucket/private/object.txt",
|
||||
litellm_params={"bucket_name": "my-bucket"},
|
||||
litellm_params={"gcs_bucket_name": "my-bucket"},
|
||||
)
|
||||
|
||||
def test_should_reject_request_supplied_legacy_flag(self, config):
|
||||
|
|
@ -86,7 +86,7 @@ class TestParseGcsUri:
|
|||
config._parse_gcs_uri(
|
||||
"gs://my-bucket/private/object.txt",
|
||||
litellm_params={
|
||||
"bucket_name": "my-bucket",
|
||||
"gcs_bucket_name": "my-bucket",
|
||||
"allow_legacy_cloud_file_ids": True,
|
||||
},
|
||||
)
|
||||
|
|
@ -96,7 +96,7 @@ class TestParseGcsUri:
|
|||
bucket, encoded = config._parse_gcs_uri(
|
||||
"gs://my-bucket/private/object.txt",
|
||||
litellm_params={
|
||||
"bucket_name": "my-bucket",
|
||||
"gcs_bucket_name": "my-bucket",
|
||||
"_litellm_internal_model_credentials": trusted_credentials,
|
||||
},
|
||||
)
|
||||
|
|
@ -109,7 +109,7 @@ class TestParseGcsUri:
|
|||
config._parse_gcs_uri(
|
||||
"gs://my-bucket/private/object.txt",
|
||||
litellm_params={
|
||||
"bucket_name": "my-bucket",
|
||||
"gcs_bucket_name": "my-bucket",
|
||||
"_litellm_internal_model_credentials": {
|
||||
"allow_legacy_cloud_file_ids": True
|
||||
},
|
||||
|
|
@ -121,7 +121,7 @@ class TestParseGcsUri:
|
|||
bucket, encoded = config._parse_gcs_uri(
|
||||
"gs://my-bucket/team-a/private/object.txt",
|
||||
litellm_params={
|
||||
"bucket_name": "my-bucket/team-a",
|
||||
"gcs_bucket_name": "my-bucket/team-a",
|
||||
"_litellm_internal_model_credentials": trusted_credentials,
|
||||
},
|
||||
)
|
||||
|
|
@ -135,7 +135,7 @@ class TestParseGcsUri:
|
|||
config._parse_gcs_uri(
|
||||
"gs://my-bucket/team-b/private/object.txt",
|
||||
litellm_params={
|
||||
"bucket_name": "my-bucket/team-a",
|
||||
"gcs_bucket_name": "my-bucket/team-a",
|
||||
"_litellm_internal_model_credentials": trusted_credentials,
|
||||
},
|
||||
)
|
||||
|
|
@ -144,7 +144,7 @@ class TestParseGcsUri:
|
|||
with pytest.raises(ValueError, match="configured storage bucket"):
|
||||
config._parse_gcs_uri(
|
||||
"gs://other-bucket/litellm-vertex-files/object.txt",
|
||||
litellm_params={"bucket_name": "my-bucket"},
|
||||
litellm_params={"gcs_bucket_name": "my-bucket"},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ class TestCreateFileUrl:
|
|||
model="",
|
||||
optional_params={},
|
||||
litellm_params={
|
||||
"bucket_name": "safe-bucket",
|
||||
"gcs_bucket_name": "safe-bucket",
|
||||
"litellm_metadata": {"gcs_bucket_name": "attacker-bucket"},
|
||||
},
|
||||
data={
|
||||
|
|
@ -182,7 +182,7 @@ class TestTransformRetrieveFile:
|
|||
url, params = config.transform_retrieve_file_request(
|
||||
file_id=file_id,
|
||||
optional_params={},
|
||||
litellm_params={"bucket_name": "my-bucket"},
|
||||
litellm_params={"gcs_bucket_name": "my-bucket"},
|
||||
)
|
||||
expected_encoded = urllib.parse.quote(
|
||||
"litellm-vertex-files/path/to/file.jsonl", safe=""
|
||||
|
|
@ -243,7 +243,7 @@ class TestTransformFileContent:
|
|||
url, params = config.transform_file_content_request(
|
||||
file_content_request={"file_id": file_id},
|
||||
optional_params={},
|
||||
litellm_params={"bucket_name": "my-bucket"},
|
||||
litellm_params={"gcs_bucket_name": "my-bucket"},
|
||||
)
|
||||
encoded = urllib.parse.quote("litellm-vertex-files/path/to/file.jsonl", safe="")
|
||||
assert (
|
||||
|
|
@ -378,7 +378,7 @@ class TestTransformDeleteFile:
|
|||
url, params = config.transform_delete_file_request(
|
||||
file_id=file_id,
|
||||
optional_params={},
|
||||
litellm_params={"bucket_name": "my-bucket"},
|
||||
litellm_params={"gcs_bucket_name": "my-bucket"},
|
||||
)
|
||||
encoded = urllib.parse.quote("litellm-vertex-files/path/to/file.jsonl", safe="")
|
||||
assert (
|
||||
|
|
@ -854,6 +854,106 @@ class TestVertexBatchOutputTransformation:
|
|||
)
|
||||
assert transformed_content == invalid_content
|
||||
|
||||
def test_binary_content_passthrough(self, config):
|
||||
"""A binary file (PDF/video) whose first bytes are not valid UTF-8 must be
|
||||
returned unchanged. The row-by-row transform only engages for a JSONL
|
||||
batch output and must never line-parse or corrupt binary content."""
|
||||
binary = b"%PDF-1.4\n%\xc4\xe5\xf2\xe5\xeb\xa7\n" + b"\x00\x01\x02\xff\xfe" * 64
|
||||
assert config._try_transform_vertex_batch_output_to_openai(binary) == binary
|
||||
|
||||
def test_streaming_transform_peaks_below_list_pipeline(self, config):
|
||||
"""The output transform must stream row-by-row, not build a list of every
|
||||
parsed row and a second list of transformed rows. This guards against a
|
||||
regression to the list pipeline, which peaks at several full copies and
|
||||
OOMs on large result files. The relative comparison cancels shared noise
|
||||
(per-row transform cost, GC timing) and only the list overhead differs.
|
||||
"""
|
||||
import gc
|
||||
import tracemalloc
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
VertexGeminiConfig,
|
||||
)
|
||||
|
||||
def vertex_row(index: int) -> dict:
|
||||
return {
|
||||
"status": "",
|
||||
"processed_time": "2024-11-01T18:13:16.826+00:00",
|
||||
"request": {
|
||||
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
|
||||
"labels": {"litellm_custom_id": f"r-{index}"},
|
||||
},
|
||||
"response": {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [{"text": "hello " * 20}],
|
||||
"role": "model",
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
}
|
||||
],
|
||||
"modelVersion": "gemini-2.0-flash-001",
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 20,
|
||||
"totalTokenCount": 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
content = ("\n".join(json.dumps(vertex_row(i)) for i in range(4000))).encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
def list_pipeline() -> bytes:
|
||||
gemini_config = VertexGeminiConfig()
|
||||
logging_obj = Logging(
|
||||
model="",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="batch_transform",
|
||||
start_time=0.1,
|
||||
litellm_call_id="",
|
||||
function_id="",
|
||||
)
|
||||
logging_obj.optional_params = {}
|
||||
mock_response = httpx.Response(
|
||||
status_code=200,
|
||||
headers={"content-type": "application/json"},
|
||||
request=httpx.Request("POST", "https://example.com"),
|
||||
)
|
||||
rows = content.decode("utf-8").strip().split("\n")
|
||||
transformed = [
|
||||
json.dumps(
|
||||
config._transform_single_vertex_batch_output_to_openai(
|
||||
json.loads(row), gemini_config, logging_obj, mock_response
|
||||
)
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return "\n".join(transformed).encode("utf-8")
|
||||
|
||||
def peak_of(fn) -> int:
|
||||
gc.collect()
|
||||
tracemalloc.start()
|
||||
try:
|
||||
fn()
|
||||
return tracemalloc.get_traced_memory()[1]
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
|
||||
streaming_peak = peak_of(
|
||||
lambda: config._try_transform_vertex_batch_output_to_openai(content)
|
||||
)
|
||||
list_peak = peak_of(list_pipeline)
|
||||
|
||||
assert streaming_peak < list_peak * 0.75, (
|
||||
f"streaming peak {streaming_peak} is not a clear win over the list "
|
||||
f"pipeline {list_peak} (ratio {streaming_peak / list_peak:.2f})"
|
||||
)
|
||||
|
||||
|
||||
class TestTryTransformDoesNotMutateCallerLoggingObj:
|
||||
"""Regression tests: _try_transform_vertex_batch_output_to_openai must not mutate
|
||||
|
|
@ -953,12 +1053,23 @@ class TestTryTransformDoesNotMutateCallerLoggingObj:
|
|||
assert transformed["response"]["status_code"] == 200
|
||||
|
||||
|
||||
def _wrap_entries(openai_jsonl_content):
|
||||
"""Vertex-wrapped requests for a list of OpenAI batch entries, built via the
|
||||
live single-entry transform that the streaming upload path uses."""
|
||||
cfg = VertexAIFilesConfig()
|
||||
return [
|
||||
_openai_batch_jsonl_entry_to_vertex_wrapped_request(
|
||||
entry, cfg._map_openai_to_vertex_params
|
||||
)
|
||||
for entry in openai_jsonl_content
|
||||
]
|
||||
|
||||
|
||||
class TestVertexBatchCustomIdLabels:
|
||||
"""Test custom_id handling in batch transformations"""
|
||||
|
||||
def test_custom_id_added_to_labels_in_vertex_request(self):
|
||||
"""Test that custom_id from OpenAI format is added as a label in Vertex AI format"""
|
||||
transformation = VertexAIJsonlFilesTransformation()
|
||||
|
||||
openai_jsonl_content = [
|
||||
{
|
||||
|
|
@ -973,11 +1084,7 @@ class TestVertexBatchCustomIdLabels:
|
|||
}
|
||||
]
|
||||
|
||||
vertex_jsonl_content = (
|
||||
transformation._transform_openai_jsonl_content_to_vertex_ai_jsonl_content(
|
||||
openai_jsonl_content
|
||||
)
|
||||
)
|
||||
vertex_jsonl_content = _wrap_entries(openai_jsonl_content)
|
||||
|
||||
assert len(vertex_jsonl_content) == 1
|
||||
vertex_request = vertex_jsonl_content[0]
|
||||
|
|
@ -992,7 +1099,6 @@ class TestVertexBatchCustomIdLabels:
|
|||
|
||||
def test_long_custom_id_round_trips_across_raw_label_chunks(self):
|
||||
"""Test that long custom_ids are not truncated in raw labels."""
|
||||
transformation = VertexAIJsonlFilesTransformation()
|
||||
custom_id_a = "shared-prefix-that-is-longer-than-thirty-six-bytes-A"
|
||||
custom_id_b = "shared-prefix-that-is-longer-than-thirty-six-bytes-B"
|
||||
|
||||
|
|
@ -1009,11 +1115,7 @@ class TestVertexBatchCustomIdLabels:
|
|||
for custom_id in (custom_id_a, custom_id_b)
|
||||
]
|
||||
|
||||
vertex_jsonl_content = (
|
||||
transformation._transform_openai_jsonl_content_to_vertex_ai_jsonl_content(
|
||||
openai_jsonl_content
|
||||
)
|
||||
)
|
||||
vertex_jsonl_content = _wrap_entries(openai_jsonl_content)
|
||||
labels_a = vertex_jsonl_content[0]["request"]["labels"]
|
||||
labels_b = vertex_jsonl_content[1]["request"]["labels"]
|
||||
|
||||
|
|
@ -1028,7 +1130,6 @@ class TestVertexBatchCustomIdLabels:
|
|||
|
||||
def test_multiple_requests_each_get_their_own_label(self):
|
||||
"""Test that multiple requests each get their own custom_id label"""
|
||||
transformation = VertexAIJsonlFilesTransformation()
|
||||
|
||||
openai_jsonl_content = [
|
||||
{
|
||||
|
|
@ -1043,11 +1144,7 @@ class TestVertexBatchCustomIdLabels:
|
|||
for i in range(3)
|
||||
]
|
||||
|
||||
vertex_jsonl_content = (
|
||||
transformation._transform_openai_jsonl_content_to_vertex_ai_jsonl_content(
|
||||
openai_jsonl_content
|
||||
)
|
||||
)
|
||||
vertex_jsonl_content = _wrap_entries(openai_jsonl_content)
|
||||
|
||||
assert len(vertex_jsonl_content) == 3
|
||||
|
||||
|
|
@ -1063,7 +1160,6 @@ class TestVertexBatchCustomIdLabels:
|
|||
|
||||
def test_request_without_custom_id_has_no_label(self):
|
||||
"""Test that requests without custom_id don't get a label"""
|
||||
transformation = VertexAIJsonlFilesTransformation()
|
||||
|
||||
openai_jsonl_content = [
|
||||
{
|
||||
|
|
@ -1076,11 +1172,7 @@ class TestVertexBatchCustomIdLabels:
|
|||
}
|
||||
]
|
||||
|
||||
vertex_jsonl_content = (
|
||||
transformation._transform_openai_jsonl_content_to_vertex_ai_jsonl_content(
|
||||
openai_jsonl_content
|
||||
)
|
||||
)
|
||||
vertex_jsonl_content = _wrap_entries(openai_jsonl_content)
|
||||
|
||||
# Should not have labels if no custom_id was provided
|
||||
assert "labels" not in vertex_jsonl_content[0]["request"]
|
||||
|
|
@ -1090,7 +1182,6 @@ class TestVertexBatchCustomIdLabels:
|
|||
Test the full round trip: OpenAI format -> Vertex AI format -> Vertex AI output -> OpenAI output
|
||||
Verify that custom_id is preserved through the entire flow.
|
||||
"""
|
||||
transformation = VertexAIJsonlFilesTransformation()
|
||||
config = VertexAIFilesConfig()
|
||||
|
||||
# Step 1: Transform OpenAI input to Vertex AI format (mixed case exercises raw label)
|
||||
|
|
@ -1106,11 +1197,7 @@ class TestVertexBatchCustomIdLabels:
|
|||
}
|
||||
]
|
||||
|
||||
vertex_input = (
|
||||
transformation._transform_openai_jsonl_content_to_vertex_ai_jsonl_content(
|
||||
openai_input
|
||||
)
|
||||
)
|
||||
vertex_input = _wrap_entries(openai_input)
|
||||
|
||||
# Verify both labels are GCP-safe and encoded raw preserves round-trip.
|
||||
assert (
|
||||
|
|
@ -1154,7 +1241,6 @@ class TestVertexBatchCustomIdLabels:
|
|||
|
||||
def test_custom_id_label_sanitization(self):
|
||||
"""Test that custom_id values are sanitized to meet GCP label constraints"""
|
||||
transformation = VertexAIJsonlFilesTransformation()
|
||||
|
||||
# Test sanitization function
|
||||
assert _sanitize_gcp_label_value("MyRequest-1") == "myrequest-1"
|
||||
|
|
@ -1179,11 +1265,7 @@ class TestVertexBatchCustomIdLabels:
|
|||
}
|
||||
]
|
||||
|
||||
vertex_input = (
|
||||
transformation._transform_openai_jsonl_content_to_vertex_ai_jsonl_content(
|
||||
openai_input
|
||||
)
|
||||
)
|
||||
vertex_input = _wrap_entries(openai_input)
|
||||
|
||||
# Verify both labels are safe for GCP labels.
|
||||
assert (
|
||||
|
|
@ -1192,3 +1274,47 @@ class TestVertexBatchCustomIdLabels:
|
|||
raw_label = vertex_input[0]["request"]["labels"]["litellm_custom_id_raw"]
|
||||
assert raw_label != "MyRequest-1"
|
||||
assert _sanitize_gcp_label_value(raw_label) == raw_label
|
||||
|
||||
|
||||
class TestConfiguredBucketNameResolution:
|
||||
def test_should_resolve_new_gcs_bucket_name_key(self, config, monkeypatch):
|
||||
monkeypatch.delenv("GCS_BUCKET_NAME", raising=False)
|
||||
assert (
|
||||
config._get_configured_bucket_name({"gcs_bucket_name": "my-new-bucket"})
|
||||
== "my-new-bucket"
|
||||
)
|
||||
|
||||
def test_should_resolve_legacy_bucket_name_key(self, config, monkeypatch):
|
||||
monkeypatch.delenv("GCS_BUCKET_NAME", raising=False)
|
||||
assert (
|
||||
config._get_configured_bucket_name({"bucket_name": "my-legacy-bucket"})
|
||||
== "my-legacy-bucket"
|
||||
)
|
||||
|
||||
def test_should_prefer_new_key_over_legacy(self, config, monkeypatch):
|
||||
monkeypatch.delenv("GCS_BUCKET_NAME", raising=False)
|
||||
assert (
|
||||
config._get_configured_bucket_name(
|
||||
{"gcs_bucket_name": "new", "bucket_name": "legacy"}
|
||||
)
|
||||
== "new"
|
||||
)
|
||||
|
||||
def test_should_fall_back_to_env(self, config, monkeypatch):
|
||||
monkeypatch.setenv("GCS_BUCKET_NAME", "env-bucket")
|
||||
assert config._get_configured_bucket_name({}) == "env-bucket"
|
||||
|
||||
def test_should_raise_when_no_bucket_anywhere(self, config, monkeypatch):
|
||||
monkeypatch.delenv("GCS_BUCKET_NAME", raising=False)
|
||||
with pytest.raises(ValueError, match="GCS bucket_name is required"):
|
||||
config._get_configured_bucket_name({})
|
||||
|
||||
def test_legacy_kwarg_survives_get_litellm_params(self):
|
||||
from litellm.litellm_core_utils.get_litellm_params import (
|
||||
_OPTIONAL_KWARGS_KEYS,
|
||||
get_litellm_params,
|
||||
)
|
||||
|
||||
assert "bucket_name" in _OPTIONAL_KWARGS_KEYS
|
||||
params = get_litellm_params(bucket_name="my-legacy-bucket")
|
||||
assert params.get("bucket_name") == "my-legacy-bucket"
|
||||
|
|
|
|||
|
|
@ -3714,3 +3714,54 @@ async def test_inference_route_still_enforces_team_budget():
|
|||
valid_token=UserAPIKeyAuth(token="test-token", team_id="test-team"),
|
||||
request=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_virtual_key_max_budget_error_names_the_key():
|
||||
"""BudgetExceededError for a virtual key must name the key (alias + masked key)
|
||||
so operators don't have to reverse-map a spend figure back to a key."""
|
||||
valid_token = UserAPIKeyAuth(
|
||||
token="hashed-token",
|
||||
key_alias="payments-prod",
|
||||
key_name="sk-...um_g",
|
||||
max_budget=10.0,
|
||||
spend=0.0,
|
||||
)
|
||||
proxy_logging_obj = MagicMock()
|
||||
proxy_logging_obj.budget_alerts = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.get_current_spend",
|
||||
new=AsyncMock(return_value=25.0),
|
||||
):
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await _virtual_key_max_budget_check(
|
||||
valid_token=valid_token,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "payments-prod" in message
|
||||
assert "sk-...um_g" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_virtual_key_max_budget_not_exceeded_does_not_raise():
|
||||
"""Spend below the configured budget must not raise."""
|
||||
valid_token = UserAPIKeyAuth(
|
||||
token="hashed-token",
|
||||
key_alias="payments-prod",
|
||||
max_budget=10.0,
|
||||
spend=0.0,
|
||||
)
|
||||
proxy_logging_obj = MagicMock()
|
||||
proxy_logging_obj.budget_alerts = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.get_current_spend",
|
||||
new=AsyncMock(return_value=1.0),
|
||||
):
|
||||
await _virtual_key_max_budget_check(
|
||||
valid_token=valid_token,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,155 +14,131 @@ from fastapi import HTTPException
|
|||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
|
||||
|
||||
def _models(file_content_as_dict):
|
||||
"""Distinct body.model values, mirroring how the rate limiter collects the
|
||||
models from a streamed batch file before the access check."""
|
||||
return [
|
||||
entry["body"]["model"]
|
||||
for entry in file_content_as_dict
|
||||
if (entry.get("body") or {}).get("model")
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token counter — covers all three batch payload shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_token_counter_counts_chat_messages():
|
||||
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
|
||||
from litellm.batches.batch_utils import _count_entry_tokens
|
||||
|
||||
usage = _get_batch_job_input_file_usage(
|
||||
file_content_dictionary=[
|
||||
{
|
||||
"body": {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
}
|
||||
tokens = _count_entry_tokens(
|
||||
{
|
||||
"body": {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens > 0
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
def test_token_counter_counts_text_completion_prompt():
|
||||
"""Pre-fix this returned 0 tokens (the function only inspected
|
||||
"""Pre-fix this returned 0 tokens (the counter only inspected
|
||||
`messages`), letting `prompt`-style batches slip past TPM limits."""
|
||||
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
|
||||
from litellm.batches.batch_utils import _count_entry_tokens
|
||||
|
||||
usage = _get_batch_job_input_file_usage(
|
||||
file_content_dictionary=[
|
||||
{"body": {"model": "gpt-3.5-turbo-instruct", "prompt": "hello world"}}
|
||||
]
|
||||
tokens = _count_entry_tokens(
|
||||
{"body": {"model": "gpt-3.5-turbo-instruct", "prompt": "hello world"}}
|
||||
)
|
||||
assert usage.prompt_tokens > 0
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
def test_token_counter_counts_embedding_input_string():
|
||||
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
|
||||
from litellm.batches.batch_utils import _count_entry_tokens
|
||||
|
||||
usage = _get_batch_job_input_file_usage(
|
||||
file_content_dictionary=[
|
||||
{"body": {"model": "text-embedding-3-small", "input": "hello world"}}
|
||||
]
|
||||
tokens = _count_entry_tokens(
|
||||
{"body": {"model": "text-embedding-3-small", "input": "hello world"}}
|
||||
)
|
||||
assert usage.prompt_tokens > 0
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
def test_token_counter_counts_embedding_input_list():
|
||||
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
|
||||
from litellm.batches.batch_utils import _count_entry_tokens
|
||||
|
||||
usage = _get_batch_job_input_file_usage(
|
||||
file_content_dictionary=[
|
||||
{
|
||||
"body": {
|
||||
"model": "text-embedding-3-small",
|
||||
"input": ["hello", "world"],
|
||||
}
|
||||
tokens = _count_entry_tokens(
|
||||
{
|
||||
"body": {
|
||||
"model": "text-embedding-3-small",
|
||||
"input": ["hello", "world"],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens > 0
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
def test_token_counter_counts_text_completion_prompt_list():
|
||||
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
|
||||
from litellm.batches.batch_utils import _count_entry_tokens
|
||||
|
||||
usage = _get_batch_job_input_file_usage(
|
||||
file_content_dictionary=[
|
||||
{
|
||||
"body": {
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"prompt": ["alpha", "beta"],
|
||||
}
|
||||
tokens = _count_entry_tokens(
|
||||
{
|
||||
"body": {
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"prompt": ["alpha", "beta"],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens > 0
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
def test_token_counter_counts_pre_tokenized_prompt_int_list():
|
||||
"""OpenAI's text-completion API accepts a single pre-tokenized prompt as
|
||||
a list of ints. Each int is one token; pre-fix this shape was silently
|
||||
counted as zero, leaving a TPM bypass."""
|
||||
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
|
||||
from litellm.batches.batch_utils import _count_entry_tokens
|
||||
|
||||
usage = _get_batch_job_input_file_usage(
|
||||
file_content_dictionary=[
|
||||
{
|
||||
"body": {
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"prompt": [1, 2, 3, 4, 5],
|
||||
}
|
||||
tokens = _count_entry_tokens(
|
||||
{
|
||||
"body": {
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"prompt": [1, 2, 3, 4, 5],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens == 5
|
||||
assert tokens == 5
|
||||
|
||||
|
||||
def test_token_counter_counts_pre_tokenized_prompt_list_of_int_lists():
|
||||
"""Multiple pre-tokenized prompts (`list[list[int]]`) — the most
|
||||
important bypass shape. A 1000-token batch must report 1000 tokens,
|
||||
not zero."""
|
||||
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
|
||||
from litellm.batches.batch_utils import _count_entry_tokens
|
||||
|
||||
usage = _get_batch_job_input_file_usage(
|
||||
file_content_dictionary=[
|
||||
{
|
||||
"body": {
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"prompt": [[1] * 250, [2] * 250, [3] * 500],
|
||||
}
|
||||
tokens = _count_entry_tokens(
|
||||
{
|
||||
"body": {
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"prompt": [[1] * 250, [2] * 250, [3] * 500],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens == 1000
|
||||
assert tokens == 1000
|
||||
|
||||
|
||||
def test_token_counter_counts_pre_tokenized_input_for_embeddings():
|
||||
"""Same shape applies to embeddings (`input`)."""
|
||||
from litellm.batches.batch_utils import _get_batch_job_input_file_usage
|
||||
from litellm.batches.batch_utils import _count_entry_tokens
|
||||
|
||||
usage = _get_batch_job_input_file_usage(
|
||||
file_content_dictionary=[
|
||||
{
|
||||
"body": {
|
||||
"model": "text-embedding-3-small",
|
||||
"input": [[1, 2, 3], [4, 5, 6]],
|
||||
}
|
||||
tokens = _count_entry_tokens(
|
||||
{
|
||||
"body": {
|
||||
"model": "text-embedding-3-small",
|
||||
"input": [[1, 2, 3], [4, 5, 6]],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens == 6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model extractor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_model_extractor_returns_distinct_models():
|
||||
from litellm.batches.batch_utils import _get_models_from_batch_input_file_content
|
||||
|
||||
models = _get_models_from_batch_input_file_content(
|
||||
[
|
||||
{"body": {"model": "gpt-4o", "messages": []}},
|
||||
{"body": {"model": "gpt-4o", "messages": []}}, # duplicate
|
||||
{"body": {"model": "gpt-4o-mini", "messages": []}},
|
||||
{"body": {}}, # missing model
|
||||
]
|
||||
)
|
||||
assert models == ["gpt-4o", "gpt-4o-mini"]
|
||||
assert tokens == 6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -211,7 +187,7 @@ async def test_pre_call_rejects_unauthorized_model_in_batch_file():
|
|||
with pytest.raises(HTTPException) as exc:
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=file_dict,
|
||||
models=_models(file_dict),
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
|
@ -250,7 +226,7 @@ async def test_pre_call_allows_all_team_models_key_when_model_in_team_allowlist(
|
|||
with patch("litellm.proxy.proxy_server.llm_router", None):
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=file_dict,
|
||||
models=_models(file_dict),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -297,7 +273,7 @@ async def test_pre_call_uses_current_team_allowlist_for_all_team_models_key():
|
|||
):
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=file_dict,
|
||||
models=_models(file_dict),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
|
@ -358,7 +334,7 @@ async def test_pre_call_allows_all_team_models_key_via_current_team_object():
|
|||
):
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=file_dict,
|
||||
models=_models(file_dict),
|
||||
)
|
||||
|
||||
mock_get_team_object.assert_awaited_once()
|
||||
|
|
@ -421,7 +397,7 @@ async def test_pre_call_denies_all_team_models_key_via_member_scope():
|
|||
):
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=file_dict,
|
||||
models=_models(file_dict),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
|
@ -479,7 +455,7 @@ async def test_pre_call_fails_closed_when_current_team_fetch_fails_for_all_team_
|
|||
):
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=file_dict,
|
||||
models=_models(file_dict),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == expected_status
|
||||
|
|
@ -524,7 +500,7 @@ async def test_pre_call_allows_authorized_model_in_batch_file():
|
|||
# Should not raise
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=file_dict,
|
||||
models=_models(file_dict),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -744,7 +720,7 @@ async def test_pre_call_allows_stripped_provider_model_when_key_has_proxy_alias(
|
|||
):
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=file_dict,
|
||||
models=_models(file_dict),
|
||||
target_model_names=[proxy_alias],
|
||||
)
|
||||
|
||||
|
|
@ -837,7 +813,7 @@ async def test_pre_call_uses_target_model_names_not_stripped_reverse_lookup(
|
|||
):
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=file_dict,
|
||||
models=_models(file_dict),
|
||||
target_model_names=[batch_alias],
|
||||
)
|
||||
|
||||
|
|
@ -863,11 +839,11 @@ async def test_pre_call_skips_check_when_no_models_present():
|
|||
# entirely.
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=[],
|
||||
models=_models([]),
|
||||
)
|
||||
await rate_limiter._enforce_batch_file_model_access(
|
||||
user_api_key_dict=user,
|
||||
file_content_as_dict=[{"body": {}}],
|
||||
models=_models([{"body": {}}]),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1390,3 +1366,272 @@ async def test_count_input_file_usage_raises_on_non_bytes_content():
|
|||
user_api_key_dict=UserAPIKeyAuth(api_key="sk", models=["*"]),
|
||||
data={},
|
||||
)
|
||||
|
||||
|
||||
# Streaming input counting — peak memory must not scale with a full dict list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_batch_input_bytes(n_rows: int, padding: int = 200) -> bytes:
|
||||
import json as _json
|
||||
|
||||
pad = "x" * padding
|
||||
rows = []
|
||||
for i in range(n_rows):
|
||||
rows.append(
|
||||
_json.dumps(
|
||||
{
|
||||
"custom_id": f"request-{i}",
|
||||
"method": "POST",
|
||||
"url": "/v1/chat/completions",
|
||||
"body": {
|
||||
"model": "gpt-4o" if i % 2 else "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": f"{pad} {i}"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return ("\n".join(rows)).encode("utf-8")
|
||||
|
||||
|
||||
def test_iter_batch_input_entries_matches_dict_list():
|
||||
from litellm.batches.batch_utils import (
|
||||
_get_file_content_as_dictionary,
|
||||
_iter_batch_input_entries,
|
||||
)
|
||||
|
||||
raw = _make_batch_input_bytes(50)
|
||||
streamed = list(_iter_batch_input_entries(raw))
|
||||
assert streamed == _get_file_content_as_dictionary(raw)
|
||||
assert streamed[0]["custom_id"] == "request-0"
|
||||
# tolerant of blank lines and a missing trailing newline
|
||||
assert list(_iter_batch_input_entries(raw + b"\n\n")) == streamed
|
||||
|
||||
|
||||
def test_streaming_count_peak_below_dict_list():
|
||||
import gc
|
||||
import tracemalloc
|
||||
|
||||
from litellm.batches.batch_utils import (
|
||||
_get_file_content_as_dictionary,
|
||||
_iter_batch_input_entries,
|
||||
)
|
||||
|
||||
raw = _make_batch_input_bytes(8000)
|
||||
|
||||
def _measure(fn):
|
||||
gc.collect()
|
||||
tracemalloc.start()
|
||||
try:
|
||||
fn()
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
return peak
|
||||
|
||||
def _stream():
|
||||
count = 0
|
||||
models: set = set()
|
||||
for entry in _iter_batch_input_entries(raw):
|
||||
count += 1
|
||||
model = (entry.get("body") or {}).get("model")
|
||||
if model:
|
||||
models.add(model)
|
||||
return count
|
||||
|
||||
def _build_list():
|
||||
return len(_get_file_content_as_dictionary(raw))
|
||||
|
||||
stream_peak = _measure(_stream)
|
||||
list_peak = _measure(_build_list)
|
||||
assert stream_peak < list_peak * 0.5, (
|
||||
f"streaming count peak {stream_peak} is not a clear win over the dict "
|
||||
f"list {list_peak} (ratio {stream_peak / list_peak:.2f})"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_input_file_usage_streams_without_building_list():
|
||||
"""count_input_file_usage must count requests/tokens in one streaming pass.
|
||||
Mocks the download; asserts the count is correct and that the dict-list
|
||||
helper is never called (a revert to the list approach would call it)."""
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
rate_limiter = _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=MagicMock(),
|
||||
)
|
||||
raw = _make_batch_input_bytes(10)
|
||||
fake_content = MagicMock()
|
||||
fake_content.content = raw
|
||||
|
||||
with (
|
||||
patch("litellm.afile_content", new=AsyncMock(return_value=fake_content)),
|
||||
patch(
|
||||
"litellm.batches.batch_utils._get_file_content_as_dictionary"
|
||||
) as mock_dict_list,
|
||||
):
|
||||
usage = await rate_limiter.count_input_file_usage(
|
||||
file_id="file-not-managed",
|
||||
custom_llm_provider="openai",
|
||||
user_api_key_dict=None,
|
||||
)
|
||||
|
||||
assert usage.request_count == 10
|
||||
assert usage.total_tokens > 0
|
||||
mock_dict_list.assert_not_called()
|
||||
|
||||
|
||||
def _one_row_batch_bytes(model: str) -> bytes:
|
||||
import json as _json
|
||||
|
||||
return (
|
||||
_json.dumps(
|
||||
{
|
||||
"custom_id": "r0",
|
||||
"method": "POST",
|
||||
"url": "/v1/chat/completions",
|
||||
"body": {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "x"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_input_file_usage_enforces_models_when_token_counting_fails():
|
||||
"""Security regression: a row whose content makes token counting raise must
|
||||
NOT skip the model allowlist check. async_pre_call_hook swallows non-HTTP
|
||||
exceptions and submits the batch, so a raised counting error would otherwise
|
||||
fail open. The access check must still run and deny the restricted model."""
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
rate_limiter = _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=MagicMock(),
|
||||
)
|
||||
fake_content = MagicMock()
|
||||
fake_content.content = _one_row_batch_bytes("restricted-model")
|
||||
user = UserAPIKeyAuth(
|
||||
api_key="sk-x",
|
||||
user_id="bob",
|
||||
models=["only-allowed"],
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
)
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise ValueError("unsupported content part: input_audio")
|
||||
|
||||
deny = AsyncMock(side_effect=Exception("model not in allowlist"))
|
||||
|
||||
with (
|
||||
patch("litellm.afile_content", new=AsyncMock(return_value=fake_content)),
|
||||
patch("litellm.proxy.hooks.batch_rate_limiter._count_entry_tokens", new=_boom),
|
||||
patch("litellm.proxy.auth.auth_checks.can_key_call_model", new=deny),
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock(model_list=[])),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await rate_limiter.count_input_file_usage(
|
||||
file_id="file-not-managed",
|
||||
custom_llm_provider="openai",
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
|
||||
# The access check ran despite token counting failing, and denied the model.
|
||||
deny.assert_awaited()
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_input_file_usage_estimates_tokens_when_counting_fails_for_allowed_model():
|
||||
"""A token-counting failure for an allowed model must not hard-block the batch
|
||||
(the pre-streaming behavior let such batches through), but it also must not
|
||||
zero the token total, which would let a caller evade the TPM limit by sending
|
||||
rows the counter cannot measure. The row falls back to a conservative
|
||||
size-based estimate so the batch proceeds with a non-zero count."""
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
rate_limiter = _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=MagicMock(),
|
||||
)
|
||||
fake_content = MagicMock()
|
||||
fake_content.content = _one_row_batch_bytes("allowed-model")
|
||||
user = UserAPIKeyAuth(
|
||||
api_key="sk-x",
|
||||
user_id="bob",
|
||||
models=["allowed-model"],
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
)
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise ValueError("unsupported content part: file")
|
||||
|
||||
allow = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch("litellm.afile_content", new=AsyncMock(return_value=fake_content)),
|
||||
patch("litellm.proxy.hooks.batch_rate_limiter._count_entry_tokens", new=_boom),
|
||||
patch("litellm.proxy.auth.auth_checks.can_key_call_model", new=allow),
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock(model_list=[])),
|
||||
):
|
||||
usage = await rate_limiter.count_input_file_usage(
|
||||
file_id="file-not-managed",
|
||||
custom_llm_provider="openai",
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
|
||||
allow.assert_awaited()
|
||||
assert usage.request_count == 1
|
||||
# Estimated, not zeroed: a crafted uncountable row can't evade the TPM limit.
|
||||
assert usage.total_tokens > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_input_file_usage_collects_models_after_malformed_line():
|
||||
"""A malformed JSONL line must not abort model collection. A restricted model
|
||||
named on a row AFTER a malformed line must still be collected and denied by the
|
||||
allowlist check, otherwise a caller could hide a restricted model behind a bad
|
||||
row."""
|
||||
from litellm.proxy.hooks.batch_rate_limiter import _PROXY_BatchRateLimiter
|
||||
|
||||
rate_limiter = _PROXY_BatchRateLimiter(
|
||||
internal_usage_cache=MagicMock(),
|
||||
parallel_request_limiter=MagicMock(),
|
||||
)
|
||||
fake_content = MagicMock()
|
||||
fake_content.content = (
|
||||
_one_row_batch_bytes("only-allowed")
|
||||
+ b"{ this is not valid json\n"
|
||||
+ _one_row_batch_bytes("restricted-model")
|
||||
)
|
||||
user = UserAPIKeyAuth(
|
||||
api_key="sk-x",
|
||||
user_id="bob",
|
||||
models=["only-allowed"],
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
)
|
||||
|
||||
async def _deny_restricted(model, **kwargs):
|
||||
if model == "restricted-model":
|
||||
raise Exception("model not in allowlist")
|
||||
return True
|
||||
|
||||
deny = AsyncMock(side_effect=_deny_restricted)
|
||||
|
||||
with (
|
||||
patch("litellm.afile_content", new=AsyncMock(return_value=fake_content)),
|
||||
patch("litellm.proxy.auth.auth_checks.can_key_call_model", new=deny),
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock(model_list=[])),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await rate_limiter.count_input_file_usage(
|
||||
file_id="file-not-managed",
|
||||
custom_llm_provider="openai",
|
||||
user_api_key_dict=user,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
|
|
|||
|
|
@ -1067,3 +1067,40 @@ async def test_failure_hook_drops_error_information_traceback_when_env_set(
|
|||
assert "traceback" not in error_information
|
||||
assert error_information["error_class"] == "RuntimeError"
|
||||
assert error_information["error_message"] == "boom-with-traceback"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_post_call_failure_hook_records_recovered_partial_spend():
|
||||
"""A stream that broke mid-flight still billed the provider. The failure
|
||||
hook lifts the recovered cost onto request_data as ``response_cost``; this
|
||||
hook must pass it through to update_database so the failure row records the
|
||||
real partial spend instead of the hardcoded zero.
|
||||
"""
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
logger = _ProxyDBLogger()
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key="test_api_key", user_id="u", team_id="t")
|
||||
|
||||
request_data = {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"metadata": {},
|
||||
"proxy_server_request": {"request_id": "rid"},
|
||||
"response_cost": 3.5e-05,
|
||||
"combined_usage_object": Usage(
|
||||
prompt_tokens=30, completion_tokens=1, total_tokens=31
|
||||
),
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter.update_database",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update_database:
|
||||
await logger.async_post_call_failure_hook(
|
||||
request_data=request_data,
|
||||
original_exception=Exception("MidStreamFallbackError: read timeout"),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
mock_update_database.assert_called_once()
|
||||
assert mock_update_database.call_args[1]["response_cost"] == 3.5e-05
|
||||
|
|
|
|||
|
|
@ -378,6 +378,83 @@ def test_mock_create_audio_file(mocker: MockerFixture, monkeypatch, llm_router:
|
|||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
def test_create_file_batch_streams_from_upload_spool(monkeypatch, llm_router: Router):
|
||||
"""
|
||||
Batch uploads must be passed downstream as the upload's streamable file handle
|
||||
(Starlette's already-spooled file), not read into an in-memory bytes object, so
|
||||
the proxy never buffers the whole payload. Non-batch uploads keep the bytes path.
|
||||
"""
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
from litellm.proxy.openai_files_endpoints import files_endpoints as fe
|
||||
from litellm.types.llms.openai import OpenAIFileObject
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router)
|
||||
setup_proxy_logging_object(monkeypatch, llm_router)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_route_create_file(*, _create_file_request, **kwargs):
|
||||
file_elem = _create_file_request["file"][1]
|
||||
captured["file_elem"] = file_elem
|
||||
if hasattr(file_elem, "read") and hasattr(file_elem, "seek"):
|
||||
file_elem.seek(0)
|
||||
captured["streamed_content"] = file_elem.read()
|
||||
return OpenAIFileObject(
|
||||
id="dummy-id",
|
||||
object="file",
|
||||
bytes=0,
|
||||
created_at=1234567890,
|
||||
filename="batch.jsonl",
|
||||
purpose="batch",
|
||||
status="uploaded",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(fe, "route_create_file", fake_route_create_file)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user"
|
||||
)
|
||||
|
||||
content = (
|
||||
b'{"custom_id":"r-0","method":"POST","url":"/v1/chat/completions",'
|
||||
b'"body":{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"hi"}]}}\n'
|
||||
)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/v1/files",
|
||||
files={"file": ("batch.jsonl", content, "application/jsonl")},
|
||||
data={"purpose": "batch"},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
file_elem = captured["file_elem"]
|
||||
assert not isinstance(
|
||||
file_elem, (bytes, bytearray)
|
||||
), "batch upload must be a streamable handle, not in-memory bytes"
|
||||
assert hasattr(file_elem, "read") and hasattr(
|
||||
file_elem, "seek"
|
||||
), "batch upload must be a seekable file handle"
|
||||
assert (
|
||||
captured["streamed_content"] == content
|
||||
), "the handle must stream the uploaded bytes"
|
||||
|
||||
captured.clear()
|
||||
resp = client.post(
|
||||
"/v1/files",
|
||||
files={"file": ("data.jsonl", content, "application/jsonl")},
|
||||
data={"purpose": "user_data"},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert isinstance(
|
||||
captured["file_elem"], (bytes, bytearray)
|
||||
), "non-batch upload must stay in-memory bytes"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.flaky(retries=3, delay=2)
|
||||
def test_target_storage_invokes_storage_backend(
|
||||
mocker: MockerFixture, monkeypatch, llm_router: Router
|
||||
|
|
|
|||
|
|
@ -1744,3 +1744,451 @@ class TestNonStreamingResponseRedaction:
|
|||
leaked = logging_obj.model_call_details.get("complete_streaming_response")
|
||||
assert leaked is None
|
||||
assert redacted.choices[0].message.content == "redacted-by-litellm"
|
||||
|
||||
|
||||
class TestInterruptedStreamOutputTokenRecovery:
|
||||
"""
|
||||
When an Anthropic pass-through stream is interrupted (client disconnect)
|
||||
before the terminal ``message_delta``, the only usage signal is the
|
||||
``message_start`` ``output_tokens`` placeholder (typically 1-3), so
|
||||
completion tokens and spend are undercounted ~20x. The handler must
|
||||
re-tokenize the buffered ``content_block_delta`` text to recover a
|
||||
realistic ``output_tokens``; completed streams must stay untouched.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _sse(event, data):
|
||||
return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()
|
||||
|
||||
_MODEL = "claude-3-5-haiku-20241022"
|
||||
_OUTPUT_TEXT = (
|
||||
"The history of computing spans centuries, beginning with mechanical "
|
||||
"calculators and the abacus, advancing through Charles Babbage's "
|
||||
"analytical engine, Ada Lovelace's first algorithm, Alan Turing's "
|
||||
"theoretical machine, and the electronic computers of the twentieth "
|
||||
"century that gave rise to the modern information age."
|
||||
)
|
||||
|
||||
def _interrupted_chunks(self, *, placeholder_output_tokens: int = 2):
|
||||
from litellm.proxy.pass_through_endpoints.streaming_handler import (
|
||||
PassThroughStreamingHandler,
|
||||
)
|
||||
|
||||
words = self._OUTPUT_TEXT.split(" ")
|
||||
frames = [
|
||||
self._sse(
|
||||
"message_start",
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_interrupted",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": self._MODEL,
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {
|
||||
"input_tokens": 29,
|
||||
"output_tokens": placeholder_output_tokens,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
self._sse(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
),
|
||||
]
|
||||
for i, word in enumerate(words):
|
||||
text = word if i == 0 else " " + word
|
||||
frames.append(
|
||||
self._sse(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": text},
|
||||
},
|
||||
)
|
||||
)
|
||||
# Client disconnects here: no content_block_stop / message_delta /
|
||||
# message_stop are ever received.
|
||||
return list(PassThroughStreamingHandler._convert_raw_bytes_to_str_lines(frames))
|
||||
|
||||
def _completed_chunks(self, *, final_output_tokens: int = 80):
|
||||
chunks = self._interrupted_chunks()
|
||||
chunks.append(
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
||||
"usage": {"output_tokens": final_output_tokens},
|
||||
}
|
||||
)
|
||||
)
|
||||
chunks.append('data: {"type": "message_stop"}')
|
||||
return chunks
|
||||
|
||||
def _run(self, all_chunks):
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.model_call_details = {"model": self._MODEL, "stream": True}
|
||||
logging_obj.litellm_call_id = "test-call-id"
|
||||
logging_obj.litellm_params = {}
|
||||
logging_obj.get_router_model_id.return_value = None
|
||||
|
||||
return AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks(
|
||||
litellm_logging_obj=logging_obj,
|
||||
passthrough_success_handler_obj=MagicMock(),
|
||||
url_route="/anthropic/v1/messages",
|
||||
request_body={"model": self._MODEL, "stream": True},
|
||||
endpoint_type="messages",
|
||||
start_time=datetime.now(),
|
||||
all_chunks=all_chunks,
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
def test_interrupted_stream_retokenizes_buffered_output(self):
|
||||
import litellm
|
||||
|
||||
placeholder = 2
|
||||
result = self._run(
|
||||
self._interrupted_chunks(placeholder_output_tokens=placeholder)
|
||||
)
|
||||
usage = result["result"].usage
|
||||
|
||||
expected = litellm.token_counter(
|
||||
model=self._MODEL,
|
||||
text=self._OUTPUT_TEXT,
|
||||
count_response_tokens=True,
|
||||
)
|
||||
|
||||
assert expected > placeholder * 5
|
||||
assert usage.completion_tokens == expected
|
||||
assert usage.completion_tokens > placeholder
|
||||
assert usage.total_tokens == usage.prompt_tokens + expected
|
||||
# Anthropic spend is priced off completion_tokens_details.text_tokens; if the
|
||||
# placeholder leaks through here, cost stays undercounted even though
|
||||
# completion_tokens looks right.
|
||||
assert usage.completion_tokens_details.text_tokens == expected
|
||||
|
||||
def test_completed_stream_keeps_message_delta_tokens(self):
|
||||
final = 80
|
||||
result = self._run(self._completed_chunks(final_output_tokens=final))
|
||||
usage = result["result"].usage
|
||||
|
||||
# Terminal message_delta present: recovery must not fire; the authoritative
|
||||
# provider count is preserved verbatim.
|
||||
assert usage.completion_tokens == final
|
||||
|
||||
|
||||
def _sse_bytes(data: dict) -> bytes:
|
||||
return f"event: {data['type']}\ndata: {json.dumps(data)}\n\n".encode()
|
||||
|
||||
|
||||
class TestAnthropicUsageOnlyFallback:
|
||||
"""When stream_chunk_builder cannot reassemble a large/agentic stream (returns
|
||||
None or raises), Anthropic still emits token usage in the message_start /
|
||||
message_delta SSE events. The handler must recover usage-only so the request is
|
||||
priced instead of being dropped from SpendLogs while Anthropic billed the tokens."""
|
||||
|
||||
_CHUNKS = [
|
||||
_sse_bytes(
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"model": "claude-3-5-haiku-20241022",
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"cache_read_input_tokens": 40,
|
||||
"cache_creation_input_tokens": 20,
|
||||
"output_tokens": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
_sse_bytes(
|
||||
{
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"output_tokens": 55,
|
||||
"server_tool_use": {"web_search_requests": 2},
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
def test_build_usage_only_recovers_cache_inclusive_usage(self):
|
||||
response = (
|
||||
AnthropicPassthroughLoggingHandler._build_usage_only_response_from_chunks(
|
||||
all_chunks=self._CHUNKS, model="claude-3-5-haiku-20241022"
|
||||
)
|
||||
)
|
||||
assert response is not None
|
||||
usage = response.usage
|
||||
# prompt_tokens must be cache-inclusive (input + cache_read + cache_creation)
|
||||
assert usage.prompt_tokens == 160
|
||||
assert usage.completion_tokens == 55
|
||||
assert usage._cache_read_input_tokens == 40
|
||||
assert usage._cache_creation_input_tokens == 20
|
||||
assert usage.prompt_tokens_details.cached_tokens == 40
|
||||
assert usage.server_tool_use.web_search_requests == 2
|
||||
|
||||
def test_build_usage_only_returns_none_without_usage_events(self):
|
||||
chunks = [_sse_bytes({"type": "content_block_delta", "delta": {"text": "hi"}})]
|
||||
assert (
|
||||
AnthropicPassthroughLoggingHandler._build_usage_only_response_from_chunks(
|
||||
all_chunks=chunks, model="claude-3-5-haiku-20241022"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_build_usage_only_recovers_cache_split_server_tools_and_model(self):
|
||||
# the model is "unknown" up-front and only the 5m/1h cache split is sent
|
||||
# (no flat cache_creation_input_tokens); web/tool-search and geo arrive in
|
||||
# message_delta. All must be recovered and priced, not left at $0.
|
||||
chunks = [
|
||||
"event: ping\ndata: [DONE]\n\n", # ignored sentinel between real events
|
||||
_sse_bytes(
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"model": "claude-opus-4-6",
|
||||
"usage": {
|
||||
"input_tokens": 80,
|
||||
"output_tokens": 1,
|
||||
"cache_creation": {
|
||||
"ephemeral_5m_input_tokens": 12,
|
||||
"ephemeral_1h_input_tokens": 8,
|
||||
},
|
||||
"inference_geo": "us",
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
_sse_bytes(
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "tool_use"},
|
||||
"usage": {
|
||||
"output_tokens": 40,
|
||||
"cache_read_input_tokens": 5,
|
||||
"inference_geo": "us",
|
||||
"server_tool_use": {
|
||||
"web_search_requests": 1,
|
||||
"tool_search_requests": 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
response = (
|
||||
AnthropicPassthroughLoggingHandler._build_usage_only_response_from_chunks(
|
||||
all_chunks=chunks, model="unknown"
|
||||
)
|
||||
)
|
||||
assert response is not None
|
||||
assert response.model == "claude-opus-4-6"
|
||||
# the real stop_reason is surfaced, not a hardcoded "stop"
|
||||
assert response.choices[0].finish_reason == "tool_calls"
|
||||
usage = response.usage
|
||||
# 80 input + 20 cache_creation (derived from 12+8) + 5 cache_read
|
||||
assert usage.prompt_tokens == 105
|
||||
assert usage.completion_tokens == 40
|
||||
assert usage._cache_creation_input_tokens == 20
|
||||
assert usage._cache_read_input_tokens == 5
|
||||
assert usage.server_tool_use.web_search_requests == 1
|
||||
assert usage.server_tool_use.tool_search_requests == 3
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event_str,expected",
|
||||
[
|
||||
("data: [DONE]", None),
|
||||
("data: ", None),
|
||||
("data: {not-json", None),
|
||||
("event: ping", None),
|
||||
('data: {"a": 1}', {"a": 1}),
|
||||
],
|
||||
)
|
||||
def test_extract_sse_data_handles_malformed_and_sentinel_lines(
|
||||
self, event_str, expected
|
||||
):
|
||||
assert (
|
||||
AnthropicPassthroughLoggingHandler._extract_sse_data(event_str) == expected
|
||||
)
|
||||
|
||||
def _real_logging_obj(self):
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as RealLoggingObj
|
||||
|
||||
logging_obj = RealLoggingObj(
|
||||
model="claude-3-5-haiku-20241022",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="test-call-id",
|
||||
function_id="1",
|
||||
)
|
||||
logging_obj.model_call_details["litellm_params"] = {}
|
||||
logging_obj.litellm_params = {}
|
||||
return logging_obj
|
||||
|
||||
@patch("litellm.completion_cost")
|
||||
@patch.object(
|
||||
AnthropicPassthroughLoggingHandler, "_build_complete_streaming_response"
|
||||
)
|
||||
def test_handler_falls_back_when_assembly_returns_none(
|
||||
self, mock_assemble, mock_cost
|
||||
):
|
||||
mock_assemble.return_value = None
|
||||
mock_cost.return_value = 0.0021
|
||||
logging_obj = self._real_logging_obj()
|
||||
|
||||
result = AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks(
|
||||
litellm_logging_obj=logging_obj,
|
||||
passthrough_success_handler_obj=MagicMock(),
|
||||
url_route="/anthropic/v1/messages",
|
||||
request_body={"model": "claude-3-5-haiku-20241022", "stream": True},
|
||||
endpoint_type="messages",
|
||||
start_time=datetime.now(),
|
||||
all_chunks=list(self._CHUNKS),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
assert result["result"] is not None
|
||||
assert result["result"].usage.completion_tokens == 55
|
||||
assert result["kwargs"]["response_cost"] == 0.0021
|
||||
|
||||
@patch("litellm.completion_cost")
|
||||
@patch.object(
|
||||
AnthropicPassthroughLoggingHandler, "_build_complete_streaming_response"
|
||||
)
|
||||
def test_handler_falls_back_when_assembly_raises(self, mock_assemble, mock_cost):
|
||||
import litellm
|
||||
|
||||
mock_assemble.side_effect = litellm.APIError(
|
||||
status_code=500,
|
||||
message="boom",
|
||||
llm_provider="anthropic",
|
||||
model="claude-3-5-haiku-20241022",
|
||||
)
|
||||
mock_cost.return_value = 0.0021
|
||||
logging_obj = self._real_logging_obj()
|
||||
|
||||
result = AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks(
|
||||
litellm_logging_obj=logging_obj,
|
||||
passthrough_success_handler_obj=MagicMock(),
|
||||
url_route="/anthropic/v1/messages",
|
||||
request_body={"model": "claude-3-5-haiku-20241022", "stream": True},
|
||||
endpoint_type="messages",
|
||||
start_time=datetime.now(),
|
||||
all_chunks=list(self._CHUNKS),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
# a raise from stream_chunk_builder must be treated like a None result,
|
||||
# not propagate out and drop the request from SpendLogs
|
||||
assert result["result"] is not None
|
||||
assert result["result"].usage.completion_tokens == 55
|
||||
assert result["kwargs"]["response_cost"] == 0.0021
|
||||
|
||||
@patch.object(
|
||||
AnthropicPassthroughLoggingHandler, "_build_complete_streaming_response"
|
||||
)
|
||||
def test_handler_returns_none_when_no_usage_recoverable(self, mock_assemble):
|
||||
# assembly fails AND the chunks carry no usage event, so there is nothing
|
||||
# to price; the handler must return None rather than fabricate a response
|
||||
mock_assemble.return_value = None
|
||||
logging_obj = self._real_logging_obj()
|
||||
chunks = [_sse_bytes({"type": "content_block_delta", "delta": {"text": "hi"}})]
|
||||
|
||||
result = AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks(
|
||||
litellm_logging_obj=logging_obj,
|
||||
passthrough_success_handler_obj=MagicMock(),
|
||||
url_route="/anthropic/v1/messages",
|
||||
request_body={"model": "claude-3-5-haiku-20241022", "stream": True},
|
||||
endpoint_type="messages",
|
||||
start_time=datetime.now(),
|
||||
all_chunks=chunks,
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
assert result["result"] is None
|
||||
assert result["kwargs"] == {}
|
||||
|
||||
@patch.object(
|
||||
AnthropicPassthroughLoggingHandler, "_build_usage_only_response_from_chunks"
|
||||
)
|
||||
@patch.object(
|
||||
AnthropicPassthroughLoggingHandler, "_build_complete_streaming_response"
|
||||
)
|
||||
def test_handler_does_not_crash_when_usage_only_fallback_raises(
|
||||
self, mock_assemble, mock_fallback
|
||||
):
|
||||
# if the usage-only fallback itself raises, it must be treated as None and
|
||||
# drop gracefully, not propagate out and crash the success handler
|
||||
mock_assemble.return_value = None
|
||||
mock_fallback.side_effect = Exception("fallback boom")
|
||||
logging_obj = self._real_logging_obj()
|
||||
|
||||
result = AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks(
|
||||
litellm_logging_obj=logging_obj,
|
||||
passthrough_success_handler_obj=MagicMock(),
|
||||
url_route="/anthropic/v1/messages",
|
||||
request_body={"model": "claude-3-5-haiku-20241022", "stream": True},
|
||||
endpoint_type="messages",
|
||||
start_time=datetime.now(),
|
||||
all_chunks=list(self._CHUNKS),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
|
||||
assert result["result"] is None
|
||||
assert result["kwargs"] == {}
|
||||
|
||||
|
||||
class TestAnthropicResponseCostRecordedOnModelCallDetails:
|
||||
"""The pass-through success path reads spend from
|
||||
model_call_details["response_cost"], not from kwargs, so the streaming payload
|
||||
builder must record it there or streaming pass-through logs $0."""
|
||||
|
||||
def test_create_payload_records_response_cost_on_model_call_details(self):
|
||||
from litellm.types.utils import Choices, Message, ModelResponse
|
||||
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.model_call_details = {}
|
||||
logging_obj.get_router_model_id.return_value = None
|
||||
logging_obj.litellm_params = {}
|
||||
logging_obj.litellm_call_id = "test-call-id"
|
||||
|
||||
response = ModelResponse(
|
||||
id="test-id",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=Message(content="hello", role="assistant"),
|
||||
)
|
||||
],
|
||||
created=1234567890,
|
||||
model="claude-3-7-sonnet-20250219",
|
||||
usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
)
|
||||
|
||||
kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
|
||||
litellm_model_response=response,
|
||||
model="claude-3-7-sonnet-20250219",
|
||||
kwargs={},
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
assert (
|
||||
logging_obj.model_call_details["response_cost"] == kwargs["response_cost"]
|
||||
)
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
|
|
|
|||
|
|
@ -118,3 +118,20 @@ async def test_chunk_processor_does_not_schedule_logging_when_no_chunks():
|
|||
|
||||
assert received == []
|
||||
mock_route.assert_not_called()
|
||||
|
||||
|
||||
def test_convert_raw_bytes_survives_truncated_multibyte_sequence():
|
||||
"""A stream cut mid-multibyte-sequence (client disconnect) must still decode
|
||||
via errors="replace" so the usage events already received are logged, instead
|
||||
of raising UnicodeDecodeError and dropping the whole request from SpendLogs."""
|
||||
# the 3-byte "☃" (E2 98 83) is cut after 2 bytes, leaving an invalid sequence
|
||||
# that strict utf-8 decode would raise on, discarding the message_delta line too
|
||||
truncated_codepoint = "☃".encode("utf-8")[:2]
|
||||
raw_bytes = [
|
||||
b'data: {"text": "' + truncated_codepoint,
|
||||
b'\ndata: {"type": "message_delta"}\n',
|
||||
]
|
||||
|
||||
lines = PassThroughStreamingHandler._convert_raw_bytes_to_str_lines(raw_bytes)
|
||||
|
||||
assert any('"type": "message_delta"' in line for line in lines)
|
||||
|
|
|
|||
|
|
@ -2073,3 +2073,50 @@ def test_sanitize_error_information_redacts_pydantic_assignment_form(
|
|||
assert sanitized is not None
|
||||
assert "leaked-via-pydantic-msg" not in sanitized["error_message"]
|
||||
assert REDACTED_BY_LITELM_STRING in sanitized["error_message"]
|
||||
|
||||
|
||||
def test_get_logging_payload_uses_recovered_combined_usage_on_failure():
|
||||
"""A request that fails mid-stream has no usable response_obj usage, but the
|
||||
streaming handler recovers the usage from the chunks already delivered and
|
||||
the failure hook surfaces it as ``combined_usage_object``. The spend-log
|
||||
payload must record those token counts instead of zero.
|
||||
"""
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
kwargs = {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"call_type": "acompletion",
|
||||
"litellm_params": {"metadata": {"user_api_key": "sk-test"}},
|
||||
"combined_usage_object": Usage(
|
||||
prompt_tokens=30, completion_tokens=1, total_tokens=31
|
||||
),
|
||||
}
|
||||
response_obj = Exception("MidStreamFallbackError: read timeout")
|
||||
now = datetime.datetime.now(timezone.utc)
|
||||
|
||||
payload = get_logging_payload(
|
||||
kwargs=kwargs, response_obj=response_obj, start_time=now, end_time=now
|
||||
)
|
||||
|
||||
assert payload["prompt_tokens"] == 30
|
||||
assert payload["completion_tokens"] == 1
|
||||
assert payload["total_tokens"] == 31
|
||||
|
||||
|
||||
def test_get_logging_payload_failure_without_recovered_usage_is_zero():
|
||||
"""A failure with no recovered usage keeps zero token counts, so the
|
||||
combined-usage override never invents tokens for ordinary failures.
|
||||
"""
|
||||
kwargs = {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"call_type": "acompletion",
|
||||
"litellm_params": {"metadata": {"user_api_key": "sk-test"}},
|
||||
}
|
||||
response_obj = Exception("BadRequestError")
|
||||
now = datetime.datetime.now(timezone.utc)
|
||||
|
||||
payload = get_logging_payload(
|
||||
kwargs=kwargs, response_obj=response_obj, start_time=now, end_time=now
|
||||
)
|
||||
|
||||
assert payload["total_tokens"] == 0
|
||||
|
|
|
|||
|
|
@ -482,3 +482,56 @@ class TestPostCallFailureHookProxyExceptionLogging:
|
|||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
class TestPostCallFailureHookLiftsRecoveredPartialSpend:
|
||||
"""A stream that broke mid-flight still billed the provider for the chunks
|
||||
already delivered. The streaming handler stashes that recovered usage and
|
||||
cost on the logging object; post_call_failure_hook must lift them onto
|
||||
request_data before the logging object is popped, so the failure-path spend
|
||||
callbacks (which run after the pop) record the real partial spend.
|
||||
"""
|
||||
|
||||
async def _run(self, request_data):
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
|
||||
proxy_logging_obj.alert_types = []
|
||||
with patch.object(proxy_logging_obj, "update_request_status", new=AsyncMock()):
|
||||
await proxy_logging_obj.post_call_failure_hook(
|
||||
request_data=request_data,
|
||||
original_exception=Exception("boom"),
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifts_recovered_usage_and_cost(self):
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
recovered_usage = Usage(prompt_tokens=30, completion_tokens=1, total_tokens=31)
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.model_call_details = {
|
||||
"combined_usage_object": recovered_usage,
|
||||
"response_cost": 3.5e-05,
|
||||
}
|
||||
request_data = {"litellm_logging_obj": logging_obj, "metadata": {}}
|
||||
await self._run(request_data)
|
||||
|
||||
assert request_data["combined_usage_object"] is recovered_usage
|
||||
assert request_data["response_cost"] == 3.5e-05
|
||||
assert "litellm_logging_obj" not in request_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_recovered_usage_is_noop(self):
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.model_call_details = {}
|
||||
request_data = {"litellm_logging_obj": logging_obj, "metadata": {}}
|
||||
await self._run(request_data)
|
||||
assert "combined_usage_object" not in request_data
|
||||
assert "response_cost" not in request_data
|
||||
|
||||
|
||||
from litellm.proxy.utils import create_model_info_response
|
||||
from litellm.types.router import ModelGroupInfo
|
||||
|
|
|
|||
|
|
@ -2778,6 +2778,36 @@ def test_get_deployment_credentials_with_provider_aws_bedrock_runtime_endpoint()
|
|||
assert credentials["custom_llm_provider"] == "bedrock"
|
||||
|
||||
|
||||
def test_get_deployment_credentials_with_provider_includes_bucket_name():
|
||||
"""
|
||||
Regression: bucket_name must survive the CredentialLiteLLMParams filter so
|
||||
managed-files batch retrieval can resolve the GCS/S3 bucket. Previously it was
|
||||
dropped, causing "GCS bucket_name is required" when fetching batch output files.
|
||||
"""
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "vertex-gemini",
|
||||
"litellm_params": {
|
||||
"model": "vertex_ai/gemini-3.5-flash",
|
||||
"vertex_project": "my-project",
|
||||
"vertex_location": "global",
|
||||
"gcs_bucket_name": "my-batch-bucket",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
credentials = router.get_deployment_credentials_with_provider(
|
||||
model_id="vertex-gemini"
|
||||
)
|
||||
|
||||
assert credentials is not None
|
||||
assert credentials["gcs_bucket_name"] == "my-batch-bucket"
|
||||
assert credentials["vertex_project"] == "my-project"
|
||||
assert credentials["custom_llm_provider"] == "vertex_ai"
|
||||
|
||||
|
||||
def test_get_deployment_credentials_with_provider_resolves_credential_name():
|
||||
"""
|
||||
Test that get_deployment_credentials_with_provider correctly resolves
|
||||
|
|
@ -3463,6 +3493,151 @@ def test_combine_fallback_usage():
|
|||
assert chunk.usage.total_tokens == 15
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_streaming_iterator_does_not_log_success_on_terminal_failure():
|
||||
"""A mid-stream failure with no successful fallback raises and is logged as
|
||||
a failure, so the router must never dispatch it as a success. Partial-spend
|
||||
recovery for the failure row happens in the streaming handler, not here, so
|
||||
this guards only against reintroducing a success log for a failed stream.
|
||||
"""
|
||||
from litellm.exceptions import MidStreamFallbackError
|
||||
from litellm.types.utils import Delta, StreamingChoices, Usage
|
||||
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"litellm_params": {"model": "gpt-4", "api_key": "fake-key-1"},
|
||||
},
|
||||
],
|
||||
set_verbose=True,
|
||||
)
|
||||
|
||||
error = MidStreamFallbackError(
|
||||
message="Connection lost",
|
||||
model="gpt-4",
|
||||
llm_provider="openai",
|
||||
generated_content="The Roman Empire began when",
|
||||
)
|
||||
|
||||
def _make_interrupted_model_response():
|
||||
partial_chunk = litellm.ModelResponseStream(
|
||||
id="chatcmpl-partial-1",
|
||||
created=1742056047,
|
||||
model="gpt-4",
|
||||
object="chat.completion.chunk",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
finish_reason=None,
|
||||
index=0,
|
||||
delta=Delta(
|
||||
content="The Roman Empire began when", role="assistant"
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=Usage(prompt_tokens=17, completion_tokens=9, total_tokens=26),
|
||||
)
|
||||
|
||||
class _RaisingStream:
|
||||
def __init__(self):
|
||||
self.index = 0
|
||||
self.chunks = [partial_chunk]
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self.index == 0:
|
||||
self.index += 1
|
||||
return partial_chunk
|
||||
raise error
|
||||
|
||||
stream = _RaisingStream()
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.dispatch_success_handlers = AsyncMock()
|
||||
logging_obj.model_call_details = {}
|
||||
setattr(stream, "model", "gpt-4")
|
||||
setattr(stream, "custom_llm_provider", "openai")
|
||||
setattr(stream, "logging_obj", logging_obj)
|
||||
return stream, logging_obj
|
||||
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
initial_kwargs = {"model": "gpt-4", "stream": True}
|
||||
|
||||
# Terminal path: no successful fallback -> the error propagates and the
|
||||
# router never dispatches a success for the failed stream.
|
||||
model_response, logging_obj = _make_interrupted_model_response()
|
||||
with patch.object(
|
||||
router,
|
||||
"async_function_with_fallbacks_common_utils",
|
||||
new=AsyncMock(side_effect=error),
|
||||
):
|
||||
result = await router._acompletion_streaming_iterator(
|
||||
model_response=model_response,
|
||||
messages=messages,
|
||||
initial_kwargs=dict(initial_kwargs),
|
||||
)
|
||||
collected = []
|
||||
with pytest.raises(MidStreamFallbackError):
|
||||
async for chunk in result:
|
||||
collected.append(chunk)
|
||||
|
||||
assert len(collected) == 1
|
||||
logging_obj.dispatch_success_handlers.assert_not_called()
|
||||
|
||||
# Fallback success: the fallback stream owns success accounting via
|
||||
# _combine_fallback_usage, so this iterator must not dispatch its own.
|
||||
model_response, logging_obj = _make_interrupted_model_response()
|
||||
|
||||
class _FallbackStream:
|
||||
def __init__(self, items):
|
||||
self.items = items
|
||||
self.index = 0
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self.index >= len(self.items):
|
||||
raise StopAsyncIteration
|
||||
item = self.items[self.index]
|
||||
self.index += 1
|
||||
return item
|
||||
|
||||
fallback_stream = _FallbackStream(
|
||||
[
|
||||
litellm.ModelResponseStream(
|
||||
id="chatcmpl-fallback-1",
|
||||
model="gpt-3.5-turbo",
|
||||
object="chat.completion.chunk",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
finish_reason=None,
|
||||
index=0,
|
||||
delta=Delta(content=" continued", role="assistant"),
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
with patch.object(
|
||||
router,
|
||||
"async_function_with_fallbacks_common_utils",
|
||||
new=AsyncMock(return_value=fallback_stream),
|
||||
):
|
||||
result = await router._acompletion_streaming_iterator(
|
||||
model_response=model_response,
|
||||
messages=messages,
|
||||
initial_kwargs=dict(initial_kwargs),
|
||||
)
|
||||
collected = []
|
||||
async for chunk in result:
|
||||
collected.append(chunk)
|
||||
|
||||
assert len(collected) == 2
|
||||
logging_obj.dispatch_success_handlers.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_scoped_model_fallback():
|
||||
"""
|
||||
|
|
|
|||
260
ui/litellm-dashboard/package-lock.json
generated
260
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -68,7 +68,7 @@
|
|||
"tailwindcss": "3.4.19",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "7.3.2",
|
||||
"vite": "7.3.5",
|
||||
"vitest": "3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -756,9 +756,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -773,9 +773,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -790,9 +790,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -807,9 +807,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -824,9 +824,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -841,9 +841,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -858,9 +858,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -875,9 +875,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -892,9 +892,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -909,9 +909,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -926,9 +926,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -943,9 +943,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -960,9 +960,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
|
|
@ -977,9 +977,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -994,9 +994,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -1011,9 +1011,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -1028,9 +1028,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1045,9 +1045,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1062,9 +1062,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1079,9 +1079,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1096,9 +1096,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1113,9 +1113,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1130,9 +1130,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1147,9 +1147,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1164,9 +1164,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -1181,9 +1181,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -6147,9 +6147,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
|
@ -6160,32 +6160,32 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
|
|
@ -6926,16 +6926,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
|
@ -7292,9 +7292,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
|
|
@ -8193,10 +8193,20 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
|
|
@ -13386,9 +13396,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"version": "7.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -13770,9 +13780,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -81,20 +81,22 @@
|
|||
"tailwindcss": "3.4.19",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "7.3.2",
|
||||
"vite": "7.3.5",
|
||||
"vitest": "3.2.6"
|
||||
},
|
||||
"overrides": {
|
||||
"prismjs": "1.30.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"js-yaml": "4.2.0",
|
||||
"glob": "13.0.0",
|
||||
"minimatch": "10.2.4",
|
||||
"lodash": "4.18.1",
|
||||
"ws": "8.19.0",
|
||||
"ws": "8.21.0",
|
||||
"braces": "3.0.3",
|
||||
"brace-expansion": "5.0.6",
|
||||
"axios": "1.13.6",
|
||||
"postcss": "8.5.13"
|
||||
"postcss": "8.5.13",
|
||||
"esbuild": "0.28.1",
|
||||
"form-data": "4.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0",
|
||||
|
|
|
|||
342
uv.lock
generated
342
uv.lock
generated
|
|
@ -9,7 +9,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-06-21T03:05:17.35234Z"
|
||||
exclude-newer = "2026-06-22T01:17:31.93796Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -19,7 +19,7 @@ members = [
|
|||
"litellm-proxy-extras",
|
||||
]
|
||||
constraints = [
|
||||
{ name = "aiohttp", specifier = ">=3.13.5,<3.14" },
|
||||
{ name = "aiohttp", specifier = ">=3.14.1,<4.0" },
|
||||
{ name = "tornado", specifier = ">=6.5.6" },
|
||||
]
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.5"
|
||||
version = "3.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
|
|
@ -81,78 +81,88 @@ dependencies = [
|
|||
{ name = "frozenlist" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/85/cebc47ee74d8b408749073a1a46c6fcba13d170dc8af7e61996c6c9394ac/aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b", size = 750547, upload-time = "2026-03-31T21:56:30.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/98/afd308e35b9d3d8c9ec54c0918f1d722c86dc17ddfec272fcdbcce5a3124/aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5", size = 503535, upload-time = "2026-03-31T21:56:31.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/4d/926c183e06b09d5270a309eb50fbde7b09782bfd305dec1e800f329834fb/aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670", size = 497830, upload-time = "2026-03-31T21:56:33.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d6/f47d1c690f115a5c2a5e8938cce4a232a5be9aac5c5fb2647efcbbbda333/aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274", size = 1682474, upload-time = "2026-03-31T21:56:35.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/44/056fd37b1bb52eac760303e5196acc74d9d546631b035704ae5927f7b4ac/aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a", size = 1655259, upload-time = "2026-03-31T21:56:37.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/9f/78eb1a20c1c28ae02f6a3c0f4d7b0dcc66abce5290cadd53d78ce3084175/aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d", size = 1736204, upload-time = "2026-03-31T21:56:39.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/6c/d20d7de23f0b52b8c1d9e2033b2db1ac4dacbb470bb74c56de0f5f86bb4f/aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796", size = 1826198, upload-time = "2026-03-31T21:56:41.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/86/a6f3ff1fd795f49545a7c74b2c92f62729135d73e7e4055bf74da5a26c82/aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95", size = 1681329, upload-time = "2026-03-31T21:56:43.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/68/84cd3dab6b7b4f3e6fe9459a961acb142aaab846417f6e8905110d7027e5/aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5", size = 1560023, upload-time = "2026-03-31T21:56:45.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/2c/db61b64b0249e30f954a65ab4cb4970ced57544b1de2e3c98ee5dc24165f/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a", size = 1652372, upload-time = "2026-03-31T21:56:47.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/6f/e96988a6c982d047810c772e28c43c64c300c943b0ed5c1c0c4ce1e1027c/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73", size = 1662031, upload-time = "2026-03-31T21:56:48.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/26/a56feace81f3d347b4052403a9d03754a0ab23f7940780dada0849a38c92/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297", size = 1708118, upload-time = "2026-03-31T21:56:50.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/6e/b6173a8ff03d01d5e1a694bc06764b5dad1df2d4ed8f0ceec12bb3277936/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074", size = 1548667, upload-time = "2026-03-31T21:56:52.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/13/13296ffe2c132d888b3fe2c195c8b9c0c24c89c3fa5cc2c44464dc23b22e/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e", size = 1724490, upload-time = "2026-03-31T21:56:54.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1f1c287f4a79782ef36e5a6e62954c85343bc30470d862d30bd5f26c9fa2/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7", size = 1667109, upload-time = "2026-03-31T21:56:56.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/42/8461a2aaf60a8f4ea4549a4056be36b904b0eb03d97ca9a8a2604681a500/aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9", size = 439478, upload-time = "2026-03-31T21:56:58.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/71/06956304cb5ee439dfe8d86e1b2e70088bd88ed1ced1f42fb29e5d855f0e/aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76", size = 462047, upload-time = "2026-03-31T21:57:00.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1152,48 +1162,48 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
version = "48.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2919,16 +2929,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langchain"
|
||||
version = "1.2.10"
|
||||
version = "1.3.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langgraph" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/22/a4d4ac98fc2e393537130bbfba0d71a8113e6f884d96f935923e247397fe/langchain-1.2.10.tar.gz", hash = "sha256:bdcd7218d9c79a413cf15e106e4eb94408ac0963df9333ccd095b9ed43bf3be7", size = 570071, upload-time = "2026-02-10T14:56:49.74Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/7c/651d0dc4913a7a892156c03dd343b99cfe19ee729e6911ab1f4fe7567b8b/langchain-1.3.9.tar.gz", hash = "sha256:9b14ef0db9ef314299ded858b22ca2a40b8f1b05c8c9cb6b82d53a53075fef00", size = 631514, upload-time = "2026-06-12T16:53:27.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/06/c3394327f815fade875724c0f6cff529777c96a1e17fea066deb997f8cf5/langchain-1.2.10-py3-none-any.whl", hash = "sha256:e07a377204451fffaed88276b8193e894893b1003e25c5bca6539288ccca3698", size = 111738, upload-time = "2026-02-10T14:56:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/55/3481619d21b9bdfbfda8680fba5cfc6cfe926789b8eaaad95353078cfa20/langchain-1.3.9-py3-none-any.whl", hash = "sha256:4af49ad1095799e4408b489fb79d4b8b49292453618b202d8a697fca59bb6871", size = 132873, upload-time = "2026-06-12T16:53:25.489Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3006,7 +3016,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.0"
|
||||
version = "1.4.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
|
|
@ -3019,9 +3029,9 @@ dependencies = [
|
|||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3054,14 +3064,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
version = "0.0.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3097,7 +3107,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.10"
|
||||
version = "1.2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
|
@ -3107,9 +3117,9 @@ dependencies = [
|
|||
{ name = "pydantic" },
|
||||
{ name = "xxhash" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/92/14df6fefba28c10caf1cb05aa5b8c7bf005838fe32a86d903b6c7cc4018d/langgraph-1.0.10.tar.gz", hash = "sha256:73bd10ee14a8020f31ef07e9cd4c1a70c35cc07b9c2b9cd637509a10d9d51e29", size = 511644, upload-time = "2026-02-27T21:04:38.743Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/7a/ea09b05bb0cbddfa43bd34fc581357e87fc3f21a751cc0d419688c3106da/langgraph-1.2.6.tar.gz", hash = "sha256:f9b45a34f13930c94d96cdb76277447ad2cc70ec2d18cd2764d7fdadb36cdc1b", size = 714400, upload-time = "2026-06-18T20:58:21.514Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/60/260e0c04620a37ba8916b712766c341cc5fc685dabc6948c899494bbc2ae/langgraph-1.0.10-py3-none-any.whl", hash = "sha256:7c298bef4f6ea292fcf9824d6088fe41a6727e2904ad6066f240c4095af12247", size = 160920, upload-time = "2026-02-27T21:04:35.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/32/772db1b00a9fe42f50320d1aa20caefb76e621eff1f7218b9918093d631d/langgraph-1.2.6-py3-none-any.whl", hash = "sha256:1cf94d3ca124f84f77ce408fa1b06c3dee680a8aafffe364a8fd5d7d03eb8695", size = 246132, upload-time = "2026-06-18T20:58:20.335Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3127,33 +3137,36 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.8"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langgraph-checkpoint" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/06/dd61a5c2dce009d1b03b1d56f2a85b3127659fdddf5b3be5d8f1d60820fb/langgraph_prebuilt-1.0.8.tar.gz", hash = "sha256:0cd3cf5473ced8a6cd687cc5294e08d3de57529d8dd14fdc6ae4899549efcf69", size = 164442, upload-time = "2026-02-19T18:14:39.083Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/41/ec966424ad3f2ed3996d24079d3342c8cd6c0bd0653c12b2a917a685ec6c/langgraph_prebuilt-1.0.8-py3-none-any.whl", hash = "sha256:d16a731e591ba4470f3e313a319c7eee7dbc40895bcf15c821f985a3522a7ce0", size = 35648, upload-time = "2026-02-19T18:14:37.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.3.14"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "orjson" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/134046c20bc4a4a15d410d1d21c9e298a3e9923777b4cc867b8669bc636b/langgraph_sdk-0.3.14.tar.gz", hash = "sha256:acd1674c538e97f3cdaa610f6dd7e34bc9bad30167f0ccc482dcd563325e81f5", size = 198162, upload-time = "2026-05-05T18:40:03.524Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/96/1c9f9fbfe756ddd850a2585e7f1949d8ebb97fdaa7a5eff8f45ed1314670/langgraph_sdk-0.3.14-py3-none-any.whl", hash = "sha256:68935bf6f4924eda92617a9e5dfb4f4281197508c648cb9d62ff083907607f9d", size = 97028, upload-time = "2026-05-05T18:40:02.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.3"
|
||||
version = "0.8.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
|
@ -3163,12 +3176,13 @@ dependencies = [
|
|||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
{ name = "uuid-utils" },
|
||||
{ name = "websockets" },
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/1e8ea5e8bab2a65fa95bd36229ef38e8723ec46e430e20ca2d953487a7f1/langsmith-0.8.3.tar.gz", hash = "sha256:767ff7a8d136ed42926bf99059ac631dc6883542d6e3104b32e71c7625e1fa05", size = 4460330, upload-time = "2026-05-07T19:56:56.18Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/a9/51e644c1f1dbc3dd7d22dfd6412eab206d538c81e024e4f287373544bdcb/langsmith-0.8.3-py3-none-any.whl", hash = "sha256:b2e40e308222fa0beb2dccee3b4b30bfee9062d7a4f20a3e3e93df3c51a08ab4", size = 399048, upload-time = "2026-05-07T19:56:53.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3485,7 +3499,7 @@ requires-dist = [
|
|||
{ name = "backoff", marker = "extra == 'proxy'", specifier = ">=2.2.1,<3.0" },
|
||||
{ name = "boto3", marker = "extra == 'proxy'", specifier = ">=1.43.1,<2.0" },
|
||||
{ name = "click", specifier = ">=8.0.0,<9.0" },
|
||||
{ name = "cryptography", marker = "extra == 'proxy'", specifier = ">=46.0.7,<47.0" },
|
||||
{ name = "cryptography", marker = "extra == 'proxy'", specifier = ">=48.0.1,<49.0" },
|
||||
{ name = "ddtrace", marker = "extra == 'proxy-runtime'", specifier = ">=2.19.0,<3.0" },
|
||||
{ name = "detect-secrets", marker = "extra == 'proxy-runtime'", specifier = ">=1.5.0,<2.0" },
|
||||
{ name = "diskcache", marker = "extra == 'caching'", specifier = ">=5.6.3,<6.0" },
|
||||
|
|
@ -3563,11 +3577,11 @@ ci = [
|
|||
{ name = "detect-secrets", specifier = "==1.5.0" },
|
||||
{ name = "google-generativeai", specifier = "==0.8.6" },
|
||||
{ name = "jsonlines", specifier = "==4.0.0" },
|
||||
{ name = "langchain", specifier = "==1.2.10" },
|
||||
{ name = "langchain", specifier = "==1.3.9" },
|
||||
{ name = "langchain-mcp-adapters", specifier = "==0.2.1" },
|
||||
{ name = "langchain-openai", specifier = "==1.1.14" },
|
||||
{ name = "langgraph", specifier = "==1.0.10" },
|
||||
{ name = "langgraph-prebuilt", specifier = "==1.0.8" },
|
||||
{ name = "langgraph", specifier = ">=1.2.4,<1.3.0" },
|
||||
{ name = "langgraph-prebuilt", specifier = ">=1.1.0,<1.3.0" },
|
||||
{ name = "logfire", specifier = "==4.6.0" },
|
||||
{ name = "lunary", marker = "python_full_version == '3.10.*'", specifier = "==1.4.36" },
|
||||
{ name = "lunary", marker = "python_full_version >= '3.11'", specifier = "==1.4.37" },
|
||||
|
|
@ -3615,7 +3629,7 @@ dev = [
|
|||
{ name = "types-redis", specifier = "==4.6.0.20241004" },
|
||||
{ name = "types-requests", specifier = "==2.32.4.20260107" },
|
||||
{ name = "types-setuptools", specifier = "==75.8.0.20250225" },
|
||||
{ name = "vcrpy", specifier = "==8.1.1" },
|
||||
{ name = "vcrpy", specifier = "==8.2.1" },
|
||||
]
|
||||
healthcheck = [
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
|
|
@ -3635,7 +3649,7 @@ proxy-dev = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.42"
|
||||
version = "0.1.42.post2"
|
||||
source = { editable = "enterprise" }
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3977,7 +3991,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "mlflow"
|
||||
version = "3.11.1"
|
||||
version = "3.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
@ -4004,14 +4018,14 @@ dependencies = [
|
|||
{ name = "sqlalchemy" },
|
||||
{ name = "waitress", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/34/e328c073cd32c186fb242a957e5bade82433c06bc45b7d1695bf4d02f166/mlflow-3.11.1.tar.gz", hash = "sha256:84e54c4be91b5b2a19039a2673fe688b1d7307ceddacc08af51f8df05b19ee56", size = 9797469, upload-time = "2026-04-07T14:26:58.463Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/0b/3404a057daceffe9ce18cd08868648a1e9b817270177bdf8a764576b988b/mlflow-3.14.0.tar.gz", hash = "sha256:5a1f818fa003035c724162096ce3ded7bc7bc47a1cae595df6173961983f4718", size = 11792369, upload-time = "2026-06-17T07:57:44.712Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/62/96826c340354638dfedcbdbcd35d67754566bd45f6592300e0c215c80e30/mlflow-3.11.1-py3-none-any.whl", hash = "sha256:8f6bf1238ac04f97664c229dd480380c5c254a78bdb3c0e433e3a0397508b1af", size = 10479141, upload-time = "2026-04-07T14:26:55.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/b9/76dcdef7f7f856b36f18cfcd752c2717d9847812a0aaa36d50a7baed569d/mlflow-3.14.0-py3-none-any.whl", hash = "sha256:dbf77f7cdb5b5c0ec59b4671c61730b1b914b4dff7a2892e267a547cb5454f56", size = 12564161, upload-time = "2026-06-17T07:57:42.348Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mlflow-skinny"
|
||||
version = "3.11.1"
|
||||
version = "3.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
|
|
@ -4031,17 +4045,18 @@ dependencies = [
|
|||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/77/fe2027ddad9e52ed1ac360fbc262169e6366f6678632e350cbd0d901bb9b/mlflow_skinny-3.11.1.tar.gz", hash = "sha256:86ce63491349f6713afc8a4ef0bf77a8314d0e79e03753cb150d6c860a0b0475", size = 2642799, upload-time = "2026-04-07T14:26:43.818Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/4f/a054cd8860590e4e942aee1aab3c94307878159f945fa844acc9ea787721/mlflow_skinny-3.14.0.tar.gz", hash = "sha256:e50f4506422c7737157ae6643c165122af7898345f2e828fa93c4f10128653cf", size = 2901772, upload-time = "2026-06-17T07:57:44.252Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a7/e61ec397b34dc3c9e91572f45e41617f429d5c524d38a4e1aa2316ee1b5e/mlflow_skinny-3.11.1-py3-none-any.whl", hash = "sha256:82ffd5f6980320b4ac19f741e7a754faa1d01707e632b002ea68e04fd25a0535", size = 3171551, upload-time = "2026-04-07T14:26:41.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/e7/b80f76ce689b9d6f21cdb84abb2b02148a1149e63a6428dd2c629cefd061/mlflow_skinny-3.14.0-py3-none-any.whl", hash = "sha256:a4880e086365871ef9d78e727a34ea5fb1ce615689579998d48e8c65ee1665a9", size = 3462788, upload-time = "2026-06-17T07:57:42.583Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mlflow-tracing"
|
||||
version = "3.11.1"
|
||||
version = "3.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
|
|
@ -4053,9 +4068,9 @@ dependencies = [
|
|||
{ name = "protobuf" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/77/73af163432f3c66e2d213045250972e504a6683c76f63dd1abfba441a16a/mlflow_tracing-3.11.1.tar.gz", hash = "sha256:cb63cee16385d081467ec5bee4807fe1af59ddfdf04be4c79e7a7813b1002193", size = 1314550, upload-time = "2026-04-07T14:26:32.785Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/34/ff5e72919b4eec8fe65e6fc843978a1a512194e6fafbd1761deca48269ad/mlflow_tracing-3.14.0.tar.gz", hash = "sha256:c2f701e001d35964f23fbbdfdda36c818a76c157b912ae83781199fd714be09a", size = 1429017, upload-time = "2026-06-17T07:58:00.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/ab/d980c84e7df4224ab8db2457afbe135b430f371ca081a37cf89f8ef18ca1/mlflow_tracing-3.11.1-py3-none-any.whl", hash = "sha256:fa82df64dacf8293b714ae666440fe7c1902c6470c024df389bb91e9de3106d9", size = 1575790, upload-time = "2026-04-07T14:26:30.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/4a/4658a9e514c8f079e40b608661844b9beb21c7530e6ee1e7f830cf81541e/mlflow_tracing-3.14.0-py3-none-any.whl", hash = "sha256:854488dd18068f15e2a56f1cc7b8868c611d09ea39068d0a691a3f07e0048cae", size = 1703863, upload-time = "2026-06-17T07:57:58.687Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5930,16 +5945,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.14.1"
|
||||
version = "2.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5987,11 +6002,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.0"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -6052,14 +6070,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.13.2"
|
||||
version = "6.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/0a/48fe05c6bb3aa4bb4d2a4079a383d33c0dfec1edf613a642f07d8b8b5c2e/pypdf-6.13.2.tar.gz", hash = "sha256:5a96a17dbdfbf9c2ab24c0a13fa0aba182be22ba6f283098712c16fc242f509f", size = 6479250, upload-time = "2026-06-10T16:42:34.5Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/18/9947cc201af9ccf76720fd3347bf4f70eb882ce3fcf4cb05f7443e4cf871/pypdf-6.13.3.tar.gz", hash = "sha256:f3cb822769725f1bac658c406cfc9460399043f3750c2d3e4650e0a85eacabd7", size = 6484063, upload-time = "2026-06-17T15:22:00.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/17/378943705992f74e451a06de3401ce68e3213763c81e44d0614559c45599/pypdf-6.13.2-py3-none-any.whl", hash = "sha256:6eeb9e57693f29d41bd01255d02660cbbb41fd7fc818a982677389a35e4f2083", size = 346555, upload-time = "2026-06-10T16:42:32.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/56/2967e621598987905fb8cdfadd8f8de6b5c68c9351f0523c4df8409f28f1/pypdf-6.13.3-py3-none-any.whl", hash = "sha256:c6e3f86afb625791510b02ad5480e94b63970bb957df75d44657c282ecc52224", size = 347288, upload-time = "2026-06-17T15:21:59.512Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6279,11 +6297,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.27"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7403,15 +7421,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.1.0"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7944,15 +7962,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "vcrpy"
|
||||
version = "8.1.1"
|
||||
version = "8.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/07/bcfd5ebd7cb308026ab78a353e091bd699593358be49197d39d004e5ad83/vcrpy-8.1.1.tar.gz", hash = "sha256:58e3053e33b423f3594031cb758c3f4d1df931307f1e67928e30cf352df7709f", size = 85770, upload-time = "2026-01-04T19:22:03.886Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/db/08183b845b0040bb877dad2bd7e4e0976fc232bb3476d7ee369c6c4f8b5a/vcrpy-8.2.1.tar.gz", hash = "sha256:d73a6e4eb6dae8148e659764b7a00e68cc51ba29ba9e6a85e1f0790ad96b97df", size = 90511, upload-time = "2026-06-16T13:20:52.906Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/d7/f79b05a5d728f8786876a7d75dfb0c5cae27e428081b2d60152fb52f155f/vcrpy-8.1.1-py3-none-any.whl", hash = "sha256:2d16f31ad56493efb6165182dd99767207031b0da3f68b18f975545ede8ac4b9", size = 42445, upload-time = "2026-01-04T19:22:02.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7c/0e812ab83f5289404c674f3461ba783250b967d34b5ab034d361236ec042/vcrpy-8.2.1-py3-none-any.whl", hash = "sha256:7ce58c9e2792b246f79d6f4b3e9660676cc6f853be17e1547305b4437ab1ff85", size = 44925, upload-time = "2026-06-16T13:20:51.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue