mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_autorouter_quality_signals
This commit is contained in:
commit
ce4e16d1f4
25 changed files with 625 additions and 237 deletions
4
.github/workflows/check-schema-sync.yml
vendored
4
.github/workflows/check-schema-sync.yml
vendored
|
|
@ -10,6 +10,10 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
check-sync:
|
||||
name: Verify schema.prisma copies match root
|
||||
|
|
|
|||
4
.github/workflows/conventional-commits.yml
vendored
4
.github/workflows/conventional-commits.yml
vendored
|
|
@ -14,6 +14,10 @@ on:
|
|||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
lint-pr-title:
|
||||
name: Validate PR title
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ on:
|
|||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: Block fork dependency changes
|
||||
|
|
|
|||
4
.github/workflows/helm_unit_test.yml
vendored
4
.github/workflows/helm_unit_test.yml
vendored
|
|
@ -9,6 +9,10 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
4
.github/workflows/test-linting.yml
vendored
4
.github/workflows/test-linting.yml
vendored
|
|
@ -11,6 +11,10 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
4
.github/workflows/test-litellm-ui-build.yml
vendored
4
.github/workflows/test-litellm-ui-build.yml
vendored
|
|
@ -10,6 +10,10 @@ on:
|
|||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
4
.github/workflows/test-litellm-ui-lint.yml
vendored
4
.github/workflows/test-litellm-ui-lint.yml
vendored
|
|
@ -10,6 +10,10 @@ on:
|
|||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
frontend-lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
4
.github/workflows/test-mcp.yml
vendored
4
.github/workflows/test-mcp.yml
vendored
|
|
@ -11,6 +11,10 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
4
.github/workflows/test-model-map.yaml
vendored
4
.github/workflows/test-model-map.yaml
vendored
|
|
@ -11,6 +11,10 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
validate-model-prices-json:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
_get_httpx_client,
|
||||
get_async_httpx_client,
|
||||
)
|
||||
from litellm.llms.vertex_ai.common_utils import get_vertex_base_url
|
||||
from litellm.llms.vertex_ai.common_utils import VertexAIError, get_vertex_base_url
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM
|
||||
from litellm.types.llms.openai import CreateBatchRequest
|
||||
from litellm.types.llms.vertex_ai import (
|
||||
|
|
@ -98,9 +98,6 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
data=json.dumps(vertex_batch_request),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
response=_json_response
|
||||
|
|
@ -130,8 +127,6 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
error_body[:1000],
|
||||
)
|
||||
raise
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
|
|
@ -243,7 +238,9 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
|
|
@ -293,7 +290,9 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
headers=headers,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
|
|
@ -366,7 +365,9 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response: Final = (
|
||||
|
|
@ -391,7 +392,9 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
params=params,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response: Final = (
|
||||
|
|
@ -461,7 +464,7 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
|
||||
sync_handler: Final = _get_httpx_client()
|
||||
try:
|
||||
response: Final = sync_handler.post(
|
||||
sync_handler.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
data=json.dumps({}),
|
||||
|
|
@ -475,9 +478,6 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
)
|
||||
raise
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
|
||||
# HTTPHandler.get() does not accept a timeout parameter
|
||||
retrieve_response: Final = sync_handler.get(
|
||||
url=retrieve_api_base,
|
||||
|
|
@ -489,7 +489,10 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
retrieve_response.status_code,
|
||||
retrieve_response.text[:1000],
|
||||
)
|
||||
raise Exception(f"Error: {retrieve_response.status_code} {retrieve_response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=retrieve_response.status_code,
|
||||
message=f"Error: {retrieve_response.status_code} {retrieve_response.text}",
|
||||
)
|
||||
|
||||
_json_response: Final = retrieve_response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
|
|
@ -508,7 +511,7 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
llm_provider=litellm.LlmProviders.VERTEX_AI,
|
||||
)
|
||||
try:
|
||||
response: Final = await client.post(
|
||||
await client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
data=json.dumps({}),
|
||||
|
|
@ -521,8 +524,6 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
e.response.text[:1000],
|
||||
)
|
||||
raise
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
|
||||
# AsyncHTTPHandler.get() does not accept a timeout parameter
|
||||
retrieve_response: Final = await client.get(
|
||||
|
|
@ -535,7 +536,10 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
retrieve_response.status_code,
|
||||
retrieve_response.text[:1000],
|
||||
)
|
||||
raise Exception(f"Error: {retrieve_response.status_code} {retrieve_response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=retrieve_response.status_code,
|
||||
message=f"Error: {retrieve_response.status_code} {retrieve_response.text}",
|
||||
)
|
||||
|
||||
_json_response: Final = retrieve_response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from typing import Any, Final
|
||||
from urllib.parse import unquote
|
||||
|
||||
from litellm._uuid import uuid
|
||||
from litellm.llms.vertex_ai.common_utils import (
|
||||
VertexAIError,
|
||||
_convert_vertex_datetime_to_openai_datetime,
|
||||
)
|
||||
from litellm.types.llms.openai import BatchJobStatus, CreateBatchRequest
|
||||
|
|
@ -199,16 +201,40 @@ class VertexAIBatchTransformation:
|
|||
|
||||
gcs_file_uri format: gs://litellm-testing-bucket/litellm-vertex-files/publishers/google/models/gemini-1.5-flash-001/e9412502-2c91-42a6-8e61-f5c294cc0fc8
|
||||
returns: "publishers/google/models/gemini-1.5-flash-001"
|
||||
|
||||
Raises a 400 `VertexAIError` when the uri carries no parseable model path.
|
||||
"""
|
||||
from urllib.parse import unquote
|
||||
|
||||
decoded_uri: Final = unquote(gcs_file_uri)
|
||||
|
||||
model_path: Final = decoded_uri.split("publishers/")[1]
|
||||
parts: Final = model_path.split("/")
|
||||
model: Final = f"publishers/{'/'.join(parts[:3])}"
|
||||
model: Final = cls._parse_model_from_gcs_file(gcs_file_uri)
|
||||
if model is None:
|
||||
raise VertexAIError(
|
||||
status_code=400,
|
||||
message=(
|
||||
"Vertex AI batch creation requires the model to be part of `input_file_id`, but "
|
||||
f"'{gcs_file_uri}' contains no 'publishers/<publisher>/models/<model>' path segment. "
|
||||
"Either upload the input file through LiteLLM (POST /v1/files with "
|
||||
"custom_llm_provider=vertex_ai), which encodes the model into the returned file id, or "
|
||||
"pass a uri of the form "
|
||||
"gs://<bucket>/<prefix>/publishers/<publisher>/models/<model>/<file>"
|
||||
),
|
||||
)
|
||||
return model
|
||||
|
||||
@classmethod
|
||||
def _parse_model_from_gcs_file(cls, gcs_file_uri: str) -> str | None:
|
||||
"""
|
||||
Returns the `publishers/<publisher>/models/<model>` path from a gcs uri, or None if the uri
|
||||
does not contain one.
|
||||
"""
|
||||
_, separator, model_path = unquote(gcs_file_uri).partition("publishers/")
|
||||
if not separator:
|
||||
return None
|
||||
|
||||
parts: Final = model_path.split("/")
|
||||
if len(parts) < 3 or parts[1] != "models" or not parts[2]:
|
||||
return None
|
||||
|
||||
return f"publishers/{'/'.join(parts[:3])}"
|
||||
|
||||
@classmethod
|
||||
def is_unmanaged_gcs_batch_input_file_id(cls, input_file_id: str | None) -> bool:
|
||||
"""
|
||||
|
|
@ -216,7 +242,11 @@ class VertexAIBatchTransformation:
|
|||
LiteLLM-managed unified file id) with a `publishers/` model path that
|
||||
`_get_model_from_gcs_file` can parse.
|
||||
"""
|
||||
return input_file_id is not None and input_file_id.startswith("gs://") and "publishers/" in input_file_id
|
||||
return (
|
||||
input_file_id is not None
|
||||
and input_file_id.startswith("gs://")
|
||||
and cls._parse_model_from_gcs_file(input_file_id) is not None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_bare_model_name_from_gcs_file(cls, gcs_file_uri: str) -> str:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import json
|
|||
import traceback
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Final, cast
|
||||
from typing import Any, Final, Literal, cast
|
||||
|
||||
import fastapi
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ from .completion import CompletionRequest
|
|||
from .embedding import EmbeddingRequest
|
||||
from .llms.openai import OpenAIFileObject
|
||||
from .search import SearchProvider
|
||||
from .utils import CustomPricingLiteLLMParams, ModelResponse, StandardLoggingRoutingDecision
|
||||
from .utils import (
|
||||
CustomPricingLiteLLMParams,
|
||||
MirroredPricingParams,
|
||||
ModelResponse,
|
||||
StandardLoggingRoutingDecision,
|
||||
)
|
||||
|
||||
|
||||
class ConfigurableClientsideParamsCustomAuth(TypedDict):
|
||||
|
|
@ -122,7 +127,7 @@ class UpdateRouterConfig(BaseModel):
|
|||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
class ModelInfo(MirroredPricingParams):
|
||||
id: str | None # Allow id to be optional on input, but it will always be present as a str in the model instance
|
||||
db_model: bool = False # used for proxy - to separate models which are stored in the db vs. config.
|
||||
updated_at: datetime.datetime | None = None
|
||||
|
|
@ -424,14 +429,7 @@ class DeploymentTypedDict(TypedDict, total=False):
|
|||
model_info: dict
|
||||
|
||||
|
||||
SPECIAL_MODEL_INFO_PARAMS = [
|
||||
"input_cost_per_token",
|
||||
"output_cost_per_token",
|
||||
"input_cost_per_character",
|
||||
"output_cost_per_character",
|
||||
"cache_read_input_token_cost",
|
||||
"cache_creation_input_token_cost",
|
||||
]
|
||||
SPECIAL_MODEL_INFO_PARAMS = tuple(MirroredPricingParams.model_fields)
|
||||
|
||||
|
||||
class Deployment(BaseModel):
|
||||
|
|
|
|||
|
|
@ -3245,10 +3245,23 @@ class StandardCallbackDynamicParams(TypedDict, total=False):
|
|||
litellm_disabled_callbacks: list[str] | None
|
||||
|
||||
|
||||
class CustomPricingLiteLLMParams(BaseModel):
|
||||
## CUSTOM PRICING ##
|
||||
class MirroredPricingParams(BaseModel):
|
||||
"""Pricing overrides that ``Deployment.__init__`` mirrors from ``litellm_params``
|
||||
onto ``model_info``, so both blobs hold the same rate.
|
||||
|
||||
Declared once and inherited by both sides of that mirror, so the two can't drift.
|
||||
"""
|
||||
|
||||
input_cost_per_token: float | None = None
|
||||
output_cost_per_token: float | None = None
|
||||
input_cost_per_character: float | None = None
|
||||
output_cost_per_character: float | None = None
|
||||
cache_read_input_token_cost: float | None = None
|
||||
cache_creation_input_token_cost: float | None = None
|
||||
|
||||
|
||||
class CustomPricingLiteLLMParams(MirroredPricingParams):
|
||||
## CUSTOM PRICING ##
|
||||
input_cost_per_second: float | None = None
|
||||
output_cost_per_second: float | None = None
|
||||
output_cost_per_second_1080p: float | None = None
|
||||
|
|
@ -3259,7 +3272,6 @@ class CustomPricingLiteLLMParams(BaseModel):
|
|||
# This allows any model_info parameter to be set in litellm_params
|
||||
input_cost_per_token_flex: float | None = None
|
||||
input_cost_per_token_priority: float | None = None
|
||||
cache_creation_input_token_cost: float | None = None
|
||||
cache_creation_input_token_cost_above_1hr: float | None = None
|
||||
cache_creation_input_token_cost_above_200k_tokens: float | None = None
|
||||
cache_creation_input_token_cost_above_272k_tokens: float | None = None
|
||||
|
|
@ -3268,7 +3280,6 @@ class CustomPricingLiteLLMParams(BaseModel):
|
|||
cache_creation_input_token_cost_flex: float | None = None
|
||||
cache_creation_input_token_cost_priority: float | None = None
|
||||
cache_creation_input_audio_token_cost: float | None = None
|
||||
cache_read_input_token_cost: float | None = None
|
||||
cache_read_input_token_cost_flex: float | None = None
|
||||
cache_read_input_token_cost_priority: float | None = None
|
||||
cache_read_input_token_cost_above_200k_tokens: float | None = None
|
||||
|
|
@ -3276,7 +3287,6 @@ class CustomPricingLiteLLMParams(BaseModel):
|
|||
cache_read_input_token_cost_above_272k_tokens_priority: float | None = None
|
||||
cache_read_input_token_cost_above_272k_tokens_flex: float | None = None
|
||||
cache_read_input_audio_token_cost: float | None = None
|
||||
input_cost_per_character: float | None = None
|
||||
input_cost_per_character_above_128k_tokens: float | None = None
|
||||
input_cost_per_audio_token: float | None = None
|
||||
input_cost_per_token_cache_hit: float | None = None
|
||||
|
|
@ -3298,7 +3308,6 @@ class CustomPricingLiteLLMParams(BaseModel):
|
|||
output_cost_per_token_batches: float | None = None
|
||||
output_cost_per_token_flex: float | None = None
|
||||
output_cost_per_token_priority: float | None = None
|
||||
output_cost_per_character: float | None = None
|
||||
output_cost_per_audio_token: float | None = None
|
||||
output_cost_per_token_above_128k_tokens: float | None = None
|
||||
output_cost_per_token_above_200k_tokens: float | None = None
|
||||
|
|
|
|||
|
|
@ -5769,7 +5769,7 @@ def json_schema_type(python_type_name: str):
|
|||
return python_to_json_schema_types.get(python_type_name, "string")
|
||||
|
||||
|
||||
def function_to_dict(input_function) -> dict: # noqa: C901
|
||||
def function_to_dict(input_function) -> dict:
|
||||
"""Using type hints and numpy-styled docstring,
|
||||
produce a dictionary usable for OpenAI function calling
|
||||
|
||||
|
|
|
|||
|
|
@ -56,9 +56,6 @@
|
|||
"B026": {
|
||||
"limit": 3
|
||||
},
|
||||
"B033": {
|
||||
"limit": 0
|
||||
},
|
||||
"BLE001": {
|
||||
"limit": 2924
|
||||
},
|
||||
|
|
@ -113,18 +110,6 @@
|
|||
"F401": {
|
||||
"limit": 17
|
||||
},
|
||||
"FURB136": {
|
||||
"limit": 0
|
||||
},
|
||||
"FURB168": {
|
||||
"limit": 0
|
||||
},
|
||||
"FURB188": {
|
||||
"limit": 0
|
||||
},
|
||||
"I001": {
|
||||
"limit": 0
|
||||
},
|
||||
"LOG015": {
|
||||
"limit": 5
|
||||
},
|
||||
|
|
@ -137,18 +122,9 @@
|
|||
"PERF401": {
|
||||
"limit": 12
|
||||
},
|
||||
"PERF402": {
|
||||
"limit": 0
|
||||
},
|
||||
"PERF403": {
|
||||
"limit": 34
|
||||
},
|
||||
"PIE790": {
|
||||
"limit": 0
|
||||
},
|
||||
"PIE800": {
|
||||
"limit": 0
|
||||
},
|
||||
"PIE804": {
|
||||
"limit": 18
|
||||
},
|
||||
|
|
@ -158,9 +134,6 @@
|
|||
"PLC0206": {
|
||||
"limit": 26
|
||||
},
|
||||
"PLC0208": {
|
||||
"limit": 0
|
||||
},
|
||||
"PLC0414": {
|
||||
"limit": 46
|
||||
},
|
||||
|
|
@ -170,24 +143,12 @@
|
|||
"PLR0206": {
|
||||
"limit": 1
|
||||
},
|
||||
"PLR0402": {
|
||||
"limit": 0
|
||||
},
|
||||
"PLR1704": {
|
||||
"limit": 3
|
||||
},
|
||||
"PLR1711": {
|
||||
"limit": 0
|
||||
},
|
||||
"PLR1714": {
|
||||
"limit": 257
|
||||
},
|
||||
"PLR1730": {
|
||||
"limit": 0
|
||||
},
|
||||
"PLR2044": {
|
||||
"limit": 0
|
||||
},
|
||||
"PLW0127": {
|
||||
"limit": 57
|
||||
},
|
||||
|
|
@ -206,27 +167,12 @@
|
|||
"PLW1510": {
|
||||
"limit": 2
|
||||
},
|
||||
"PYI030": {
|
||||
"limit": 0
|
||||
},
|
||||
"PYI036": {
|
||||
"limit": 3
|
||||
},
|
||||
"PYI041": {
|
||||
"limit": 0
|
||||
},
|
||||
"PYI064": {
|
||||
"limit": 0
|
||||
},
|
||||
"RET501": {
|
||||
"limit": 0
|
||||
},
|
||||
"RET504": {
|
||||
"limit": 177
|
||||
},
|
||||
"RUF010": {
|
||||
"limit": 0
|
||||
},
|
||||
"RUF012": {
|
||||
"limit": 241
|
||||
},
|
||||
|
|
@ -236,23 +182,14 @@
|
|||
"RUF019": {
|
||||
"limit": 38
|
||||
},
|
||||
"RUF022": {
|
||||
"limit": 0
|
||||
},
|
||||
"RUF023": {
|
||||
"limit": 0
|
||||
},
|
||||
"RUF046": {
|
||||
"limit": 4
|
||||
},
|
||||
"RUF051": {
|
||||
"limit": 0
|
||||
},
|
||||
"RUF059": {
|
||||
"limit": 67
|
||||
},
|
||||
"RUF100": {
|
||||
"limit": 100
|
||||
"limit": 0
|
||||
},
|
||||
"S110": {
|
||||
"limit": 218
|
||||
|
|
@ -272,18 +209,12 @@
|
|||
"SIM113": {
|
||||
"limit": 3
|
||||
},
|
||||
"SIM114": {
|
||||
"limit": 0
|
||||
},
|
||||
"SIM115": {
|
||||
"limit": 2
|
||||
},
|
||||
"SIM117": {
|
||||
"limit": 7
|
||||
},
|
||||
"SIM118": {
|
||||
"limit": 0
|
||||
},
|
||||
"SIM201": {
|
||||
"limit": 1
|
||||
},
|
||||
|
|
@ -302,9 +233,6 @@
|
|||
"TC004": {
|
||||
"limit": 5
|
||||
},
|
||||
"TC005": {
|
||||
"limit": 0
|
||||
},
|
||||
"TID251": {
|
||||
"limit": 1240
|
||||
},
|
||||
|
|
@ -323,46 +251,13 @@
|
|||
"TRY300": {
|
||||
"limit": 860
|
||||
},
|
||||
"UP006": {
|
||||
"limit": 0
|
||||
},
|
||||
"UP007": {
|
||||
"limit": 0
|
||||
},
|
||||
"UP008": {
|
||||
"limit": 0
|
||||
},
|
||||
"UP012": {
|
||||
"limit": 0
|
||||
},
|
||||
"UP018": {
|
||||
"limit": 0
|
||||
},
|
||||
"UP024": {
|
||||
"limit": 0
|
||||
},
|
||||
"UP028": {
|
||||
"limit": 2
|
||||
},
|
||||
"UP031": {
|
||||
"limit": 2
|
||||
},
|
||||
"UP032": {
|
||||
"limit": 0
|
||||
},
|
||||
"UP034": {
|
||||
"limit": 0
|
||||
},
|
||||
"UP035": {
|
||||
"limit": 0
|
||||
},
|
||||
"UP036": {
|
||||
"limit": 1
|
||||
},
|
||||
"UP037": {
|
||||
"limit": 0
|
||||
},
|
||||
"UP045": {
|
||||
"limit": 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,17 @@ extend = "ruff.toml"
|
|||
preview = true
|
||||
select = ["ANN", "ASYNC230", "B004", "B006", "B008", "B009", "B010", "B018", "B019", "B021", "B026", "B033", "BLE", "C401", "C404", "C405", "C408", "C414", "C419", "C901", "D419", "DTZ001", "DTZ003", "DTZ005", "DTZ006", "DTZ007", "DTZ011", "EXE001", "EXE002", "F401", "FURB136", "FURB168", "FURB188", "I001", "LOG015", "N999", "PERF102", "PERF401", "PERF402", "PERF403", "PIE790", "PIE800", "PIE804", "PIE810", "PLC0206", "PLC0208", "PLC0414", "PLR0124", "PLR0206", "PLR0402", "PLR1704", "PLR1711", "PLR1714", "PLR1730", "PLR2044", "PLW0127", "PLW0133", "PLW0602", "PLW0603", "PLW1508", "PLW1510", "PYI030", "PYI036", "PYI041", "PYI064", "RET501", "RET504", "RUF010", "RUF012", "RUF015", "RUF019", "RUF022", "RUF023", "RUF046", "RUF051", "RUF059", "RUF100", "S110", "S112", "SIM101", "SIM102", "SIM103", "SIM113", "SIM114", "SIM115", "SIM117", "SIM118", "SIM201", "SIM210", "SIM211", "SIM222", "SIM401", "TC004", "TC005", "TID251", "TRY002", "TRY004", "TRY201", "TRY203", "TRY300", "UP006", "UP007", "UP008", "UP012", "UP018", "UP024", "UP028", "UP031", "UP032", "UP034", "UP035", "UP036", "UP037", "UP045"]
|
||||
extend-select = []
|
||||
# Overrides the inherited list: rules this gate enforces itself must NOT be external here,
|
||||
# so this config's RUF100 flags their stale `# noqa` directives. What remains external is
|
||||
# only what other tooling enforces: every base ruff.toml rule this select list doesn't
|
||||
# re-enable (all of the default E/F families plus T20/PGH004/RUF008/RUF009, minus the
|
||||
# strict-selected F401 and RUF100; F4 is split out so stale F401 noqas stay detectable),
|
||||
# plus upstream litellm's ruff config.
|
||||
external = [
|
||||
"T20", "PGH004", "RUF008", "RUF009", "E4", "E7", "E9",
|
||||
"F402", "F404", "F406", "F407", "F5", "F6", "F7", "F8", "F9",
|
||||
"PLC0415", "E402", "BLE001", "ARG002", "S102", "S324", "S606", "D401", "F403", "F405",
|
||||
]
|
||||
|
||||
[lint.mccabe]
|
||||
max-complexity = 15
|
||||
|
|
|
|||
20
ruff.toml
20
ruff.toml
|
|
@ -1,12 +1,26 @@
|
|||
lint.ignore = ["F405", "E402", "F403"]
|
||||
lint.extend-select = ["T20", "PGH004", "RUF008", "RUF009", "RUF100"]
|
||||
# The second group is the strict gate's graduates: rules the codebase already has zero
|
||||
# violations of, so they hard-fail here instead of being ratcheted in ruff-strict-budget.json.
|
||||
# That gives editors and `ruff check --fix` the diagnostic, which the gate script cannot.
|
||||
lint.extend-select = [
|
||||
"T20", "PGH004", "RUF008", "RUF009", "RUF100",
|
||||
"B033", "FURB136", "FURB168", "FURB188", "I001", "PERF402", "PIE790", "PIE800", "PLC0208",
|
||||
"PLR0402", "PLR1711", "PLR1730", "PLR2044", "PYI030", "PYI041", "PYI064", "RET501", "RUF010",
|
||||
"RUF022", "RUF023", "RUF051", "SIM114", "SIM118", "TC005", "UP006", "UP007", "UP008", "UP012",
|
||||
"UP018", "UP024", "UP032", "UP034", "UP035", "UP037", "UP045",
|
||||
]
|
||||
# RUF100 (unused-noqa) only knows the rules enabled in THIS config, so it would strip
|
||||
# `# noqa` directives that protect rules enforced elsewhere. List those codes as external
|
||||
# so RUF100 leaves their directives alone: the strict gate (ruff-strict.toml) and upstream
|
||||
# litellm's own ruff config both rely on suppressions this config can't see.
|
||||
lint.external = [
|
||||
# Enforced by the strict-rule gate (scripts/ruff_strict_gate.py + ruff-strict.toml)
|
||||
"C901", "TID251",
|
||||
# Enforced by the strict-rule gate (scripts/ruff_strict_gate.py + ruff-strict.toml).
|
||||
# Family entries whose every strict rule graduated into extend-select above (FURB), and
|
||||
# standalone graduated codes (I001, RUF010, RUF022, RUF023, RUF051), are dropped so this
|
||||
# config's RUF100 polices their directives itself.
|
||||
"ANN", "ASYNC230", "B", "C", "D419", "DTZ", "EXE", "LOG015", "N999", "PERF",
|
||||
"PIE", "PL", "PYI", "RET", "RUF012", "RUF015", "RUF019",
|
||||
"RUF046", "RUF059", "S110", "S112", "SIM", "TC", "TID251", "TRY", "UP",
|
||||
# Enforced by upstream litellm's ruff config, but not run in this repo's CI
|
||||
"PLC0415", "E402", "BLE001", "ARG002", "S102", "S324", "S606", "D401", "F403", "F405",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ content at the merge-base with the target branch and fails (exits 1, red) if:
|
|||
* a rule was dropped from a budget (its ceiling effectively became infinite), or
|
||||
* an entire budget file was deleted.
|
||||
|
||||
New rules and lowered/equal limits are fine.
|
||||
New rules and lowered/equal limits are fine. So is a rule that graduated: once a
|
||||
paired config (ruff.toml for the ruff-strict budget) selects the rule outright it
|
||||
hard-fails at the first violation, which is stricter than any ceiling the budget
|
||||
could hold, so dropping its entry tightens the guard rather than removing it.
|
||||
|
||||
This is deliberately NOT a gating check. It should turn the run red so that a
|
||||
loosening is impossible to miss in review, but it must stay OUT of the
|
||||
|
|
@ -30,7 +33,9 @@ import argparse
|
|||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import NamedTuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
|
@ -40,6 +45,7 @@ DEFAULT_BUDGETS: tuple[str, ...] = (
|
|||
"type-discipline-budget.json",
|
||||
"basedpyright-code-budget.json",
|
||||
)
|
||||
GRADUATION_CONFIGS = MappingProxyType({"ruff-strict-budget.json": "ruff.toml"})
|
||||
|
||||
|
||||
class Regression(NamedTuple):
|
||||
|
|
@ -106,24 +112,57 @@ def _limits(budget: dict) -> dict[str, int]:
|
|||
}
|
||||
|
||||
|
||||
def selectors_hard_failed_by(lint: dict) -> tuple[str, ...]:
|
||||
"""A ruff `[lint]` table's selected codes, minus anything `ignore` turns back off.
|
||||
|
||||
`lint.ignore` wins over `lint.extend-select` in ruff, so an ignored code is not
|
||||
actually enforced and must not count as a graduation.
|
||||
"""
|
||||
ignored = tuple(lint.get("ignore", ()))
|
||||
return tuple(
|
||||
selector
|
||||
for selector in lint.get("extend-select", ())
|
||||
if not (ignored and selector.startswith(ignored))
|
||||
)
|
||||
|
||||
|
||||
def graduated_selectors(rel: str) -> tuple[str, ...]:
|
||||
"""Selectors the budget's paired ruff config hard-fails, so its ceiling is moot."""
|
||||
config = GRADUATION_CONFIGS.get(rel)
|
||||
if config is None or not (REPO_ROOT / config).exists():
|
||||
return ()
|
||||
return selectors_hard_failed_by(
|
||||
tomllib.loads((REPO_ROOT / config).read_text()).get("lint", {})
|
||||
)
|
||||
|
||||
|
||||
def _regression_detail(
|
||||
rule: str,
|
||||
base_limits: dict[str, int],
|
||||
head_limits: dict[str, int],
|
||||
graduated: tuple[str, ...],
|
||||
) -> str | None:
|
||||
"""Why `rule` regressed vs base, or None when it held flat or fell.
|
||||
"""Why `rule` regressed vs base, or None when it held flat, fell, or graduated.
|
||||
|
||||
A dropped rule is terminal; otherwise the only loosening left is a raised limit.
|
||||
A dropped rule is terminal unless it graduated; otherwise the only loosening
|
||||
left is a raised limit.
|
||||
"""
|
||||
base_limit = base_limits[rule]
|
||||
if rule not in head_limits:
|
||||
if graduated and rule.startswith(graduated):
|
||||
return None
|
||||
return f"rule dropped (limit {base_limit} -> removed)"
|
||||
if head_limits[rule] > base_limit:
|
||||
return f"limit raised {base_limit} -> {head_limits[rule]}"
|
||||
return None
|
||||
|
||||
|
||||
def regressions_for(rel: str, base: dict | None, head: dict | None) -> list[Regression]:
|
||||
def regressions_for(
|
||||
rel: str,
|
||||
base: dict | None,
|
||||
head: dict | None,
|
||||
graduated: tuple[str, ...] = (),
|
||||
) -> list[Regression]:
|
||||
if base is None:
|
||||
return [] # new budget file: nothing to ratchet against yet
|
||||
if head is None:
|
||||
|
|
@ -133,7 +172,7 @@ def regressions_for(rel: str, base: dict | None, head: dict | None) -> list[Regr
|
|||
return [
|
||||
Regression(rel, rule, detail)
|
||||
for rule in sorted(base_limits)
|
||||
if (detail := _regression_detail(rule, base_limits, head_limits)) is not None
|
||||
if (detail := _regression_detail(rule, base_limits, head_limits, graduated)) is not None
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -164,7 +203,7 @@ def main() -> int:
|
|||
print(f"skip {rel}: new file (no base at {args.base} to ratchet against)")
|
||||
continue
|
||||
checked.append(rel)
|
||||
regressions.extend(regressions_for(rel, base, head))
|
||||
regressions.extend(regressions_for(rel, base, head, graduated_selectors(rel)))
|
||||
|
||||
if regressions:
|
||||
print(
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ The handler is HTTP/auth glue around the (separately-tested) pure
|
|||
``VertexAIBatchTransformation``. Each public method (create / retrieve / list /
|
||||
cancel) resolves a Vertex access token + URL, branches on ``_is_async``
|
||||
(returning the coroutine in the async case, doing the sync HTTP call otherwise),
|
||||
checks the HTTP status, and parses the JSON into ``LiteLLMBatch`` (or the OpenAI
|
||||
list shape).
|
||||
and parses the JSON into ``LiteLLMBatch`` (or the OpenAI list shape). POST-backed
|
||||
calls rely on the client's ``raise_for_status`` (non-2xx surfaces as
|
||||
``httpx.HTTPStatusError``); GET-backed calls return without raising, so the
|
||||
handler checks their status codes itself.
|
||||
|
||||
We mock only true I/O / auth seams:
|
||||
* ``_ensure_access_token`` - the Vertex credential seam. Returns a fixed
|
||||
|
|
@ -20,7 +22,7 @@ We mock only true I/O / auth seams:
|
|||
what URL/headers/body, and that the response is parsed into the litellm
|
||||
type. Sibling seams are asserted NOT called where relevant.
|
||||
|
||||
The ``_is_async`` branch, status-code error paths, and the cancel
|
||||
The ``_is_async`` branch, the error paths, and the cancel
|
||||
retrieve-after-cancel sequencing run for real.
|
||||
"""
|
||||
|
||||
|
|
@ -40,6 +42,7 @@ sys.path.insert(0, os.path.abspath("../../../../.."))
|
|||
from litellm.llms.vertex_ai.batches.handler import ( # noqa: E402
|
||||
VertexAIBatchPrediction,
|
||||
)
|
||||
from litellm.llms.vertex_ai.common_utils import VertexAIError # noqa: E402
|
||||
from litellm.types.utils import LiteLLMBatch # noqa: E402
|
||||
|
||||
HMOD = "litellm.llms.vertex_ai.batches.handler"
|
||||
|
|
@ -178,13 +181,19 @@ def test_create_batch_async_returns_coroutine_and_uses_async_client():
|
|||
sync_client.post.assert_not_called()
|
||||
|
||||
|
||||
def test_create_batch_sync_non_200_raises():
|
||||
def test_create_batch_sync_httpstatuserror_propagates():
|
||||
"""``HTTPHandler.post`` raises for non-2xx via ``raise_for_status``; the
|
||||
sync create path must surface that error, not swallow it."""
|
||||
h = _make_handler()
|
||||
client = MagicMock()
|
||||
client.post.return_value = _http_response(status_code=500)
|
||||
request = httpx.Request("POST", "https://x/batchPredictionJobs")
|
||||
err_response = httpx.Response(status_code=500, request=request, text="boom")
|
||||
client.post.side_effect = httpx.HTTPStatusError(
|
||||
"boom", request=request, response=err_response
|
||||
)
|
||||
|
||||
with patch(f"{HMOD}._get_httpx_client", return_value=client):
|
||||
with pytest.raises(Exception, match="Error: 500"):
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
h.create_batch(
|
||||
_is_async=False,
|
||||
create_batch_data=CREATE_DATA,
|
||||
|
|
@ -197,27 +206,27 @@ def test_create_batch_sync_non_200_raises():
|
|||
)
|
||||
|
||||
|
||||
def test_create_batch_async_non_200_raises():
|
||||
def test_create_batch_input_file_id_without_model_raises_400_before_post():
|
||||
"""A gs:// uri with no publishers/<publisher>/models/<model> path is a 400, not a bare 500."""
|
||||
h = _make_handler()
|
||||
async_client = MagicMock()
|
||||
async_client.post = AsyncMock(return_value=_http_response(status_code=403))
|
||||
client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(f"{HMOD}._get_httpx_client", return_value=MagicMock()),
|
||||
patch(f"{HMOD}.get_async_httpx_client", return_value=async_client),
|
||||
):
|
||||
coro = h.create_batch(
|
||||
_is_async=True,
|
||||
create_batch_data=CREATE_DATA,
|
||||
api_base=None,
|
||||
vertex_credentials=None,
|
||||
vertex_project=PROJECT,
|
||||
vertex_location=LOCATION,
|
||||
timeout=600.0,
|
||||
max_retries=None,
|
||||
)
|
||||
with pytest.raises(Exception, match="Error: 403"):
|
||||
_run(coro)
|
||||
with patch(f"{HMOD}._get_httpx_client", return_value=client):
|
||||
with pytest.raises(VertexAIError) as exc_info:
|
||||
h.create_batch(
|
||||
_is_async=False,
|
||||
create_batch_data={"input_file_id": "gs://bucket/batch-input.jsonl"},
|
||||
api_base=None,
|
||||
vertex_credentials=None,
|
||||
vertex_project=PROJECT,
|
||||
vertex_location=LOCATION,
|
||||
timeout=600.0,
|
||||
max_retries=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "gs://bucket/batch-input.jsonl" in str(exc_info.value)
|
||||
client.post.assert_not_called()
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
|
|
@ -292,7 +301,7 @@ def test_retrieve_batch_sync_non_200_raises():
|
|||
patch(f"{HMOD}._get_httpx_client", return_value=MagicMock()),
|
||||
patch(f"{HMOD}.safe_get", return_value=_http_response(status_code=404)),
|
||||
):
|
||||
with pytest.raises(Exception, match="Error: 404"):
|
||||
with pytest.raises(VertexAIError, match="Error: 404"):
|
||||
h.retrieve_batch(
|
||||
_is_async=False,
|
||||
batch_id=BATCH_ID,
|
||||
|
|
@ -438,7 +447,7 @@ def test_list_batches_sync_non_200_raises():
|
|||
client.get.return_value = _http_response(status_code=500)
|
||||
|
||||
with patch(f"{HMOD}._get_httpx_client", return_value=client):
|
||||
with pytest.raises(Exception, match="Error: 500"):
|
||||
with pytest.raises(VertexAIError, match="Error: 500"):
|
||||
h.list_batches(
|
||||
_is_async=False,
|
||||
after=None,
|
||||
|
|
@ -524,27 +533,6 @@ def test_cancel_batch_async_returns_coroutine_posts_then_retrieves():
|
|||
assert post_kwargs["url"].endswith(":cancel")
|
||||
|
||||
|
||||
def test_cancel_batch_sync_cancel_post_non_200_raises():
|
||||
h = _make_handler()
|
||||
client = MagicMock()
|
||||
client.post.return_value = _http_response(status_code=500)
|
||||
|
||||
with patch(f"{HMOD}._get_httpx_client", return_value=client):
|
||||
with pytest.raises(Exception, match="Error: 500"):
|
||||
h.cancel_batch(
|
||||
_is_async=False,
|
||||
batch_id=BATCH_ID,
|
||||
api_base=None,
|
||||
vertex_credentials=None,
|
||||
vertex_project=PROJECT,
|
||||
vertex_location=LOCATION,
|
||||
timeout=600.0,
|
||||
max_retries=None,
|
||||
)
|
||||
# cancel POST failed -> retrieve GET must never fire
|
||||
client.get.assert_not_called()
|
||||
|
||||
|
||||
def test_cancel_batch_sync_retrieve_non_200_raises():
|
||||
h = _make_handler()
|
||||
client = MagicMock()
|
||||
|
|
@ -552,7 +540,7 @@ def test_cancel_batch_sync_retrieve_non_200_raises():
|
|||
client.get.return_value = _http_response(status_code=404)
|
||||
|
||||
with patch(f"{HMOD}._get_httpx_client", return_value=client):
|
||||
with pytest.raises(Exception, match="Error: 404"):
|
||||
with pytest.raises(VertexAIError, match="Error: 404"):
|
||||
h.cancel_batch(
|
||||
_is_async=False,
|
||||
batch_id=BATCH_ID,
|
||||
|
|
@ -672,7 +660,7 @@ def test_async_retrieve_batch_non_200_raises():
|
|||
timeout=600.0,
|
||||
max_retries=None,
|
||||
)
|
||||
with pytest.raises(Exception, match="Error: 500"):
|
||||
with pytest.raises(VertexAIError, match="Error: 500"):
|
||||
_run(coro)
|
||||
|
||||
|
||||
|
|
@ -726,7 +714,7 @@ def test_async_list_batches_non_200_raises():
|
|||
timeout=600.0,
|
||||
max_retries=None,
|
||||
)
|
||||
with pytest.raises(Exception, match="Error: 500"):
|
||||
with pytest.raises(VertexAIError, match="Error: 500"):
|
||||
_run(coro)
|
||||
|
||||
|
||||
|
|
@ -761,28 +749,6 @@ def test_async_cancel_batch_httpstatuserror_and_retrieve_non_200():
|
|||
_run(coro)
|
||||
async_client.get.assert_not_awaited()
|
||||
|
||||
# (a2) cancel POST returns a plain non-200 (no exception) -> raises
|
||||
async_client_post500 = MagicMock()
|
||||
async_client_post500.post = AsyncMock(return_value=_http_response(status_code=500))
|
||||
async_client_post500.get = AsyncMock()
|
||||
with (
|
||||
patch(f"{HMOD}._get_httpx_client", return_value=MagicMock()),
|
||||
patch(f"{HMOD}.get_async_httpx_client", return_value=async_client_post500),
|
||||
):
|
||||
coro = h.cancel_batch(
|
||||
_is_async=True,
|
||||
batch_id=BATCH_ID,
|
||||
api_base=None,
|
||||
vertex_credentials=None,
|
||||
vertex_project=PROJECT,
|
||||
vertex_location=LOCATION,
|
||||
timeout=600.0,
|
||||
max_retries=None,
|
||||
)
|
||||
with pytest.raises(Exception, match="Error: 500"):
|
||||
_run(coro)
|
||||
async_client_post500.get.assert_not_awaited()
|
||||
|
||||
# (b) retrieve-after-cancel returns non-200
|
||||
async_client2 = MagicMock()
|
||||
async_client2.post = AsyncMock(return_value=_http_response(json_body={}))
|
||||
|
|
@ -801,5 +767,5 @@ def test_async_cancel_batch_httpstatuserror_and_retrieve_non_200():
|
|||
timeout=600.0,
|
||||
max_retries=None,
|
||||
)
|
||||
with pytest.raises(Exception, match="Error: 404"):
|
||||
with pytest.raises(VertexAIError, match="Error: 404"):
|
||||
_run(coro)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from litellm.llms.vertex_ai.batches.transformation import ( # noqa: E402
|
|||
VertexAIBatchTransformation,
|
||||
)
|
||||
from litellm.llms.vertex_ai.common_utils import ( # noqa: E402
|
||||
VertexAIError,
|
||||
_convert_vertex_datetime_to_openai_datetime,
|
||||
)
|
||||
from litellm.types.utils import LiteLLMBatch # noqa: E402
|
||||
|
|
@ -69,6 +70,24 @@ def test_transform_openai_request_missing_input_file_id_raises():
|
|||
T.transform_openai_batch_request_to_vertex_ai_batch_request({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_file_id",
|
||||
[
|
||||
"gs://bucket/no-model-here.jsonl",
|
||||
"gs://bucket/publishers/google/gemini-1.5-flash-001/file-uuid",
|
||||
"gs://bucket/publishers/google/models",
|
||||
"gs://bucket/publishers/google/models//file-uuid",
|
||||
],
|
||||
)
|
||||
def test_transform_openai_request_unparseable_model_raises_400(input_file_id: str):
|
||||
"""An input_file_id with no parseable model path is a client error, not an IndexError -> 500."""
|
||||
with pytest.raises(VertexAIError) as exc_info:
|
||||
T.transform_openai_batch_request_to_vertex_ai_batch_request({"input_file_id": input_file_id})
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert input_file_id in str(exc_info.value)
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# transform_vertex_ai_batch_response_to_openai_batch_response
|
||||
# =========================================================================== #
|
||||
|
|
@ -299,9 +318,29 @@ def test_get_model_from_gcs_file_url_encoded():
|
|||
assert T._get_model_from_gcs_file(encoded) == "publishers/google/models/gemini-1.5-flash-001"
|
||||
|
||||
|
||||
def test_get_model_from_gcs_file_no_publishers_raises():
|
||||
with pytest.raises(IndexError):
|
||||
def test_get_model_from_gcs_file_no_publishers_raises_400():
|
||||
with pytest.raises(VertexAIError) as exc_info:
|
||||
T._get_model_from_gcs_file("gs://bucket/no-model-here.jsonl")
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# is_unmanaged_gcs_batch_input_file_id
|
||||
# =========================================================================== #
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_file_id, expected",
|
||||
[
|
||||
(INPUT_FILE, True),
|
||||
(None, False),
|
||||
("file-abc123", False),
|
||||
("gs://bucket/no-model-here.jsonl", False),
|
||||
("gs://bucket/publishers/google/gemini-1.5-flash-001/file-uuid", False),
|
||||
],
|
||||
)
|
||||
def test_is_unmanaged_gcs_batch_input_file_id(input_file_id, expected):
|
||||
assert T.is_unmanaged_gcs_batch_input_file_id(input_file_id) is expected
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
|
|
|
|||
|
|
@ -68,6 +68,50 @@ def test_new_rule_in_head_is_clean():
|
|||
assert ratchet.regressions_for("b.json", {}, {"new-rule": _spec_of(5)}) == []
|
||||
|
||||
|
||||
def test_dropped_rule_that_graduated_to_a_hard_failing_config_is_clean():
|
||||
base = {"UP006": _spec_of(0)}
|
||||
assert ratchet.regressions_for("b.json", base, {}, graduated=("UP006",)) == []
|
||||
|
||||
|
||||
def test_graduation_matches_by_prefix_like_ruff_selectors_do():
|
||||
base = {"ANN202": _spec_of(865)}
|
||||
assert ratchet.regressions_for("b.json", base, {}, graduated=("ANN",)) == []
|
||||
|
||||
|
||||
def test_an_unrelated_graduation_does_not_excuse_a_dropped_rule():
|
||||
base = {"C901": _spec_of(3)}
|
||||
regs = ratchet.regressions_for("b.json", base, {}, graduated=("UP006", "SIM118"))
|
||||
assert [r.rule for r in regs] == ["C901"]
|
||||
assert "dropped" in regs[0].detail
|
||||
|
||||
|
||||
def test_graduation_never_excuses_a_raised_limit():
|
||||
base = {"UP006": _spec_of(0)}
|
||||
regs = ratchet.regressions_for("b.json", base, {"UP006": _spec_of(7)}, graduated=("UP006",))
|
||||
assert [r.rule for r in regs] == ["UP006"]
|
||||
assert "0 -> 7" in regs[0].detail
|
||||
|
||||
|
||||
def test_graduated_selectors_come_from_the_paired_ruff_config():
|
||||
selectors = ratchet.graduated_selectors("ruff-strict-budget.json")
|
||||
assert "UP006" in selectors
|
||||
assert "ANN" not in selectors
|
||||
|
||||
|
||||
def test_budgets_without_a_paired_config_can_never_graduate():
|
||||
assert ratchet.graduated_selectors("type-discipline-budget.json") == ()
|
||||
assert ratchet.graduated_selectors("basedpyright-code-budget.json") == ()
|
||||
|
||||
|
||||
def test_a_selector_the_config_also_ignores_does_not_count_as_graduated():
|
||||
lint = {"ignore": ["UP006"], "extend-select": ["UP006", "SIM118"]}
|
||||
assert ratchet.selectors_hard_failed_by(lint) == ("SIM118",)
|
||||
|
||||
|
||||
def test_selectors_hard_failed_by_reads_a_config_with_no_ignore_list():
|
||||
assert ratchet.selectors_hard_failed_by({"extend-select": ["UP006"]}) == ("UP006",)
|
||||
|
||||
|
||||
def test_deleted_budget_file_is_a_regression():
|
||||
regs = ratchet.regressions_for("b.json", {"LIT006": _spec_of(1)}, None)
|
||||
assert [r.rule for r in regs] == ["*"]
|
||||
|
|
|
|||
|
|
@ -1,16 +1,24 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MODULE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ruff_strict_gate.py"
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_MODULE_PATH = _REPO_ROOT / "scripts" / "ruff_strict_gate.py"
|
||||
_spec = importlib.util.spec_from_file_location("ruff_strict_gate", _MODULE_PATH)
|
||||
gate = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(gate)
|
||||
|
||||
Violation = gate.Violation
|
||||
|
||||
_ENABLED_BY_RUFF_DEFAULTS = frozenset({"F401"})
|
||||
|
||||
|
||||
def rule(name, limit):
|
||||
return {name: {"limit": limit}}
|
||||
|
|
@ -151,3 +159,213 @@ def test_base_point_mid_merge_advances_to_the_merged_in_base_tip(tmp_path):
|
|||
repo, _, base_tip = _branched_repo(tmp_path)
|
||||
_git(repo, "merge", "--no-commit", "--no-ff", "main")
|
||||
assert gate.resolve_base_point("main", cwd=repo) == base_tip
|
||||
|
||||
|
||||
def _lint_section(config_name: str) -> dict:
|
||||
return tomllib.loads((_REPO_ROOT / config_name).read_text())["lint"]
|
||||
|
||||
|
||||
def _base_external() -> tuple[str, ...]:
|
||||
return tuple(_lint_section("ruff.toml")["external"])
|
||||
|
||||
|
||||
def _strict_external() -> tuple[str, ...]:
|
||||
return tuple(_lint_section("ruff-strict.toml")["external"])
|
||||
|
||||
|
||||
def _strict_selected() -> frozenset:
|
||||
return frozenset(_lint_section("ruff-strict.toml")["select"])
|
||||
|
||||
|
||||
def _prefix_covered(code: str, prefixes: tuple[str, ...]) -> bool:
|
||||
return any(code.startswith(prefix) for prefix in prefixes)
|
||||
|
||||
|
||||
def _selected_by_the_normal_config() -> frozenset:
|
||||
return frozenset(_lint_section("ruff.toml")["extend-select"]) | _ENABLED_BY_RUFF_DEFAULTS
|
||||
|
||||
|
||||
def _budgeted_rules() -> frozenset:
|
||||
return frozenset(json.loads((_REPO_ROOT / "ruff-strict-budget.json").read_text()))
|
||||
|
||||
|
||||
def _ruff_binary() -> str | None:
|
||||
beside_interpreter = Path(sys.executable).with_name("ruff")
|
||||
return str(beside_interpreter) if beside_interpreter.exists() else shutil.which("ruff")
|
||||
|
||||
|
||||
_RUFF = _ruff_binary()
|
||||
_needs_ruff = pytest.mark.skipif(_RUFF is None, reason="ruff is not installed in this environment")
|
||||
|
||||
|
||||
def _ruff_output_for_noqa(code: str, *extra_args: str) -> str:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
_RUFF,
|
||||
"check",
|
||||
"--no-cache",
|
||||
"--stdin-filename",
|
||||
"litellm/types/_external_probe.py",
|
||||
*extra_args,
|
||||
"-",
|
||||
],
|
||||
cwd=_REPO_ROOT,
|
||||
input=f"def _probe(x: int): # noqa: {code}\n return x\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def test_every_strict_gate_rule_is_protected_from_base_ruf100():
|
||||
unprotected = frozenset(
|
||||
selector
|
||||
for selector in _strict_selected()
|
||||
if not _prefix_covered(selector, _base_external())
|
||||
and selector not in _selected_by_the_normal_config()
|
||||
)
|
||||
assert unprotected == frozenset(), (
|
||||
f"`ruff check` deletes any `# noqa` naming {sorted(unprotected)} as unused, so suppressing "
|
||||
"one of those strict-gate rules breaks lint. Cover them in ruff.toml's lint.external or "
|
||||
"enable them in its lint.extend-select."
|
||||
)
|
||||
|
||||
|
||||
def test_every_selected_rule_keeps_stale_noqa_detection_somewhere():
|
||||
policed_by_strict = frozenset(
|
||||
selector
|
||||
for selector in _strict_selected()
|
||||
if not _prefix_covered(selector, _strict_external())
|
||||
)
|
||||
policed_by_base = frozenset(
|
||||
selector
|
||||
for selector in _selected_by_the_normal_config()
|
||||
if not _prefix_covered(selector, _base_external())
|
||||
)
|
||||
shadowed = (
|
||||
_strict_selected() | _selected_by_the_normal_config()
|
||||
) - policed_by_strict - policed_by_base
|
||||
assert shadowed == frozenset(), (
|
||||
f"no config's RUF100 can ever report a stale `# noqa` for {sorted(shadowed)}: every config "
|
||||
"that selects each of them also shadows it with an external entry. Narrow the external "
|
||||
"entry in ruff.toml or ruff-strict.toml."
|
||||
)
|
||||
|
||||
|
||||
_BASE_OWNED_FAMILY = re.compile(r"E[479]\d+|F\d+|T20\d+")
|
||||
_BASE_OWNED_SINGLES = frozenset({"PGH004", "RUF008", "RUF009", "RUF100"})
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def all_ruff_rule_codes() -> frozenset:
|
||||
listing = subprocess.run(
|
||||
[_RUFF, "rule", "--all", "--output-format", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert listing.returncode == 0, listing.stderr
|
||||
return frozenset(
|
||||
entry["code"] for entry in json.loads(listing.stdout) if "Removed" not in entry["status"]
|
||||
)
|
||||
|
||||
|
||||
@_needs_ruff
|
||||
def test_every_base_owned_rule_is_external_or_selected_in_the_strict_config(all_ruff_rule_codes):
|
||||
base_owned = frozenset(
|
||||
code
|
||||
for code in all_ruff_rule_codes
|
||||
if _BASE_OWNED_FAMILY.fullmatch(code) or code in _BASE_OWNED_SINGLES
|
||||
)
|
||||
stranded = frozenset(
|
||||
code
|
||||
for code in base_owned
|
||||
if code not in _strict_selected() and not _prefix_covered(code, _strict_external())
|
||||
)
|
||||
assert stranded == frozenset(), (
|
||||
f"the strict gate's RUF100 reads a valid `# noqa` for {sorted(stranded)} as unused, the "
|
||||
"spurious-breach trap ruff-strict.toml's external override exists to prevent. Cover them "
|
||||
"there."
|
||||
)
|
||||
double_booked = frozenset(
|
||||
code
|
||||
for code in base_owned
|
||||
if code in _strict_selected() and _prefix_covered(code, _strict_external())
|
||||
)
|
||||
assert double_booked == frozenset(), (
|
||||
f"{sorted(double_booked)} are selected by the strict config yet shadowed by its external "
|
||||
"list, so their stale suppressions can never be reported. Narrow the external entry in "
|
||||
"ruff-strict.toml."
|
||||
)
|
||||
|
||||
|
||||
def test_every_budgeted_rule_is_one_the_gate_actually_measures():
|
||||
selectors = tuple(_lint_section("ruff-strict.toml")["select"])
|
||||
unmeasured = frozenset(code for code in _budgeted_rules() if not code.startswith(selectors))
|
||||
assert unmeasured == frozenset(), (
|
||||
f"the gate never counts {sorted(unmeasured)}, so their ceilings are dead config that reads "
|
||||
"as coverage. Either select them in ruff-strict.toml or drop them from the budget."
|
||||
)
|
||||
|
||||
|
||||
@_needs_ruff
|
||||
def test_every_strict_selected_rule_is_budgeted_or_hard_failed_by_the_base_config(all_ruff_rule_codes):
|
||||
strict_enabled = frozenset(
|
||||
code
|
||||
for code in all_ruff_rule_codes
|
||||
if code.startswith(tuple(_lint_section("ruff-strict.toml")["select"]))
|
||||
)
|
||||
base_hard_failed = tuple(_lint_section("ruff.toml")["extend-select"])
|
||||
unpoliced = frozenset(
|
||||
code
|
||||
for code in strict_enabled
|
||||
if code not in _budgeted_rules()
|
||||
and not code.startswith(base_hard_failed)
|
||||
and code not in _ENABLED_BY_RUFF_DEFAULTS
|
||||
)
|
||||
assert unpoliced == frozenset(), (
|
||||
f"nothing enforces {sorted(unpoliced)}: the gate skips rules missing from the budget, and "
|
||||
"the base config does not hard-fail them. Re-add a budget ceiling or graduate them into "
|
||||
"ruff.toml's lint.extend-select."
|
||||
)
|
||||
|
||||
|
||||
@_needs_ruff
|
||||
def test_a_noqa_for_a_strict_gate_rule_survives_the_normal_ruff_run():
|
||||
assert "RUF100" not in _ruff_output_for_noqa("ANN202")
|
||||
|
||||
|
||||
@_needs_ruff
|
||||
def test_the_external_list_is_what_saves_that_noqa():
|
||||
assert "RUF100" in _ruff_output_for_noqa("ANN202", "--config", "lint.external=[]")
|
||||
|
||||
|
||||
@_needs_ruff
|
||||
def test_a_stale_noqa_for_a_locally_enabled_rule_is_still_reported():
|
||||
assert "RUF100" in _ruff_output_for_noqa("F401")
|
||||
|
||||
|
||||
def _ruff_output_for_source(source: str) -> str:
|
||||
proc = subprocess.run(
|
||||
[_RUFF, "check", "--no-cache", "--stdin-filename", "litellm/types/_graduate_probe.py", "-"],
|
||||
cwd=_REPO_ROOT,
|
||||
input=source,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
_DEPRECATED_TYPING_ALIAS = "from typing import List # noqa: UP035\n\n\ndef _probe(x: List[int]) -> None: ...\n"
|
||||
|
||||
|
||||
@_needs_ruff
|
||||
def test_a_graduated_rule_now_fails_the_normal_ruff_run_instead_of_waiting_for_the_gate():
|
||||
assert "UP006" in _ruff_output_for_source(_DEPRECATED_TYPING_ALIAS)
|
||||
|
||||
|
||||
@_needs_ruff
|
||||
def test_a_graduated_rule_can_still_be_suppressed_without_tripping_unused_noqa():
|
||||
suppressed = _DEPRECATED_TYPING_ALIAS.replace("...\n", "... # noqa: UP006\n")
|
||||
output = _ruff_output_for_source(suppressed)
|
||||
assert "UP006" not in output
|
||||
assert "RUF100" not in output
|
||||
|
|
|
|||
73
tests/test_litellm/types/test_router.py
Normal file
73
tests/test_litellm/types/test_router.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import pytest
|
||||
|
||||
from litellm.types.router import (
|
||||
SPECIAL_MODEL_INFO_PARAMS,
|
||||
Deployment,
|
||||
LiteLLM_Params,
|
||||
ModelInfo,
|
||||
)
|
||||
from litellm.types.utils import CustomPricingLiteLLMParams, MirroredPricingParams
|
||||
|
||||
|
||||
def test_model_info_declares_mirrored_pricing_fields():
|
||||
"""The pricing keys Deployment mirrors onto model_info must be declared fields, not
|
||||
extras that only survive because ModelInfo sets extra="allow"."""
|
||||
for field in SPECIAL_MODEL_INFO_PARAMS:
|
||||
assert field in ModelInfo.model_fields
|
||||
|
||||
info = ModelInfo(id="x", input_cost_per_token=1e-06)
|
||||
assert info.__pydantic_extra__ == {}
|
||||
assert info.input_cost_per_token == 1e-06
|
||||
|
||||
|
||||
def test_special_model_info_params_cannot_drift_from_the_mirror():
|
||||
assert SPECIAL_MODEL_INFO_PARAMS == tuple(MirroredPricingParams.model_fields)
|
||||
assert set(SPECIAL_MODEL_INFO_PARAMS) <= set(CustomPricingLiteLLMParams.model_fields)
|
||||
assert set(SPECIAL_MODEL_INFO_PARAMS) <= set(LiteLLM_Params.model_fields)
|
||||
|
||||
|
||||
def test_custom_pricing_params_keeps_every_field_it_had():
|
||||
"""The mirrored fields moved to a base class; none of them may go missing from
|
||||
CustomPricingLiteLLMParams, whose model_fields drive custom-pricing detection."""
|
||||
for field in (
|
||||
"input_cost_per_token",
|
||||
"output_cost_per_token",
|
||||
"input_cost_per_character",
|
||||
"output_cost_per_character",
|
||||
"cache_read_input_token_cost",
|
||||
"cache_creation_input_token_cost",
|
||||
"input_cost_per_second",
|
||||
"cache_read_input_token_cost_flex",
|
||||
"input_cost_per_character_above_128k_tokens",
|
||||
"output_cost_per_audio_token",
|
||||
):
|
||||
assert field in CustomPricingLiteLLMParams.model_fields
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", SPECIAL_MODEL_INFO_PARAMS)
|
||||
def test_deployment_mirrors_pricing_from_litellm_params_onto_model_info(field):
|
||||
deployment = Deployment(
|
||||
model_name="my-model",
|
||||
litellm_params=LiteLLM_Params(model="gpt-4o", **{field: 3e-06}),
|
||||
)
|
||||
assert getattr(deployment.model_info, field) == 3e-06
|
||||
assert deployment.model_info.model_dump(exclude_none=True)[field] == 3e-06
|
||||
|
||||
|
||||
def test_unset_pricing_is_still_absent_from_dumps():
|
||||
"""/model/info responses and DB writes dump model_info with exclude_none=True, so
|
||||
declaring the pricing fields must not start emitting ~6 null keys per deployment."""
|
||||
dumped = ModelInfo(id="x").model_dump(exclude_none=True)
|
||||
assert [field for field in SPECIAL_MODEL_INFO_PARAMS if field in dumped] == []
|
||||
|
||||
|
||||
def test_pricing_strings_are_coerced_to_float():
|
||||
"""Cost values arrive from the DB and the Admin UI as strings; they must land as
|
||||
floats so cost calculation doesn't multiply a str."""
|
||||
info = ModelInfo(id="x", output_cost_per_token="0.000002")
|
||||
assert info.output_cost_per_token == 2e-06
|
||||
|
||||
|
||||
def test_invalid_pricing_is_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
ModelInfo(id="x", input_cost_per_token="free")
|
||||
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -35400,6 +35400,10 @@ export interface components {
|
|||
base_model?: string | null;
|
||||
/** Blocked */
|
||||
blocked?: boolean | null;
|
||||
/** Cache Creation Input Token Cost */
|
||||
cache_creation_input_token_cost?: number | null;
|
||||
/** Cache Read Input Token Cost */
|
||||
cache_read_input_token_cost?: number | null;
|
||||
/** Created At */
|
||||
created_at?: string | null;
|
||||
/** Created By */
|
||||
|
|
@ -35411,6 +35415,14 @@ export interface components {
|
|||
db_model: boolean;
|
||||
/** Id */
|
||||
id: string | null;
|
||||
/** Input Cost Per Character */
|
||||
input_cost_per_character?: number | null;
|
||||
/** Input Cost Per Token */
|
||||
input_cost_per_token?: number | null;
|
||||
/** Output Cost Per Character */
|
||||
output_cost_per_character?: number | null;
|
||||
/** Output Cost Per Token */
|
||||
output_cost_per_token?: number | null;
|
||||
/** Team Id */
|
||||
team_id?: string | null;
|
||||
/** Team Public Model Name */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue