mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #36725 from BerriAI/litellm_internal_staging
Some checks failed
Unit Tests: LLM Provider Transformations / All Other Providers (push) Has been cancelled
Unit Tests: MCP, Secrets, Containers & Misc / misc (push) Has been cancelled
Unit Tests: Proxy Auth & Key Management / proxy-auth (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI Coverage / assert-ci-coverage (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CodSpeed Benchmarks / benchmarks (push) Has been cancelled
Helm unit test / unit-test (push) Has been cancelled
Unit Tests: Responses, Caching & Types / responses-caching-types (push) Has been cancelled
GitHub Actions Security Analysis / zizmor (push) Has been cancelled
Code Quality Checks / code-quality (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Unit Tests: Core Utilities / core-utils (push) Has been cancelled
Unit Tests: Documentation Validation / documentation (push) Has been cancelled
Unit Tests: Enterprise, Google GenAI & Routing / enterprise-routing (push) Has been cancelled
Unit Tests: Integrations (Callbacks & Logging) / integrations (push) Has been cancelled
Unit Tests: LLM Provider Transformations / Vertex AI (push) Has been cancelled
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Proxy API Endpoints / proxy-endpoints (push) Has been cancelled
Unit Tests: Proxy API Endpoints / proxy-server (push) Has been cancelled
Unit Tests: Proxy Infrastructure / proxy-infra (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / auth-and-jwt (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / key-generation (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-config (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-response-and-misc (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-server (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-server-extras (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-token-counter (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-user-auth-and-spend (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-utils (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled
Some checks failed
Unit Tests: LLM Provider Transformations / All Other Providers (push) Has been cancelled
Unit Tests: MCP, Secrets, Containers & Misc / misc (push) Has been cancelled
Unit Tests: Proxy Auth & Key Management / proxy-auth (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI Coverage / assert-ci-coverage (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CodSpeed Benchmarks / benchmarks (push) Has been cancelled
Helm unit test / unit-test (push) Has been cancelled
Unit Tests: Responses, Caching & Types / responses-caching-types (push) Has been cancelled
GitHub Actions Security Analysis / zizmor (push) Has been cancelled
Code Quality Checks / code-quality (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Unit Tests: Core Utilities / core-utils (push) Has been cancelled
Unit Tests: Documentation Validation / documentation (push) Has been cancelled
Unit Tests: Enterprise, Google GenAI & Routing / enterprise-routing (push) Has been cancelled
Unit Tests: Integrations (Callbacks & Logging) / integrations (push) Has been cancelled
Unit Tests: LLM Provider Transformations / Vertex AI (push) Has been cancelled
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Proxy API Endpoints / proxy-endpoints (push) Has been cancelled
Unit Tests: Proxy API Endpoints / proxy-server (push) Has been cancelled
Unit Tests: Proxy Infrastructure / proxy-infra (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / auth-and-jwt (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / key-generation (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-config (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-response-and-misc (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-server (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-server-extras (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-token-counter (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-user-auth-and-spend (push) Has been cancelled
Unit Tests: Proxy Legacy Tests / proxy-utils (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled
ci: promote staging to main
This commit is contained in:
commit
bd0d13566e
380 changed files with 20640 additions and 12502 deletions
54
.github/workflows/test-terraform-modules.yml
vendored
Normal file
54
.github/workflows/test-terraform-modules.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
name: Terraform Modules
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "terraform/litellm/aws/**"
|
||||
- ".github/workflows/test-terraform-modules.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
paths:
|
||||
- "terraform/litellm/aws/**"
|
||||
- ".github/workflows/test-terraform-modules.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
aws-module:
|
||||
name: fmt, validate, test (aws)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: terraform/litellm/aws
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
with:
|
||||
terraform_version: 1.13.3
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: fmt
|
||||
run: terraform fmt -recursive -check -diff
|
||||
|
||||
- name: init
|
||||
run: terraform init -backend=false -input=false
|
||||
|
||||
- name: validate
|
||||
run: terraform validate
|
||||
|
||||
# Plan-only, mock_provider-backed: no AWS credentials, no API calls.
|
||||
- name: test
|
||||
run: terraform test
|
||||
2
.github/workflows/test-unit-proxy-db.yml
vendored
2
.github/workflows/test-unit-proxy-db.yml
vendored
|
|
@ -135,8 +135,6 @@ jobs:
|
|||
test-path: >-
|
||||
tests/proxy_unit_tests/test_proxy_server.py
|
||||
tests/proxy_unit_tests/test_proxy_server_keys.py
|
||||
tests/proxy_unit_tests/test_proxy_server_caching.py
|
||||
tests/proxy_unit_tests/test_proxy_server_langfuse.py
|
||||
tests/proxy_unit_tests/test_proxy_server_spend.py
|
||||
tests/proxy_unit_tests/test_aproxy_startup.py
|
||||
workers: 4
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ End-to-end tests belong in `tests/e2e/` and must follow the harness conventions
|
|||
|
||||
When creating PRs, don't set base to `main`. `litellm_internal_staging` is the default base branch and serves that purpose for both internal and external / OSS contributions
|
||||
|
||||
When writing a PR body, treat the comments and imperative instructions inside @.github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule
|
||||
When writing a PR body, treat the comments and imperative instructions inside .github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule
|
||||
|
||||
Same applies for filing bug reports and feature requests, with .github/ISSUE_TEMPLATE/bug_report.yml and .github/ISSUE_TEMPLATE/feature_request.yml, respectively
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"reportAny": {
|
||||
"limit": 23919
|
||||
"limit": 23914
|
||||
},
|
||||
"reportArgumentType": {
|
||||
"limit": 2580
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ spec:
|
|||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
restartPolicy: OnFailure
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
|
|
|||
|
|
@ -290,3 +290,27 @@ tests:
|
|||
value:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
- it: should schedule onto the same nodes as the gateway
|
||||
template: migrations-job.yaml
|
||||
set:
|
||||
migrationJob:
|
||||
enabled: true
|
||||
nodeSelector:
|
||||
karpenter.sh/nodepool: litellm-e2e
|
||||
tolerations:
|
||||
- key: workload
|
||||
operator: Equal
|
||||
value: litellm-e2e
|
||||
effect: NoSchedule
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.spec.nodeSelector
|
||||
value:
|
||||
karpenter.sh/nodepool: litellm-e2e
|
||||
- equal:
|
||||
path: spec.template.spec.tolerations
|
||||
value:
|
||||
- key: workload
|
||||
operator: Equal
|
||||
value: litellm-e2e
|
||||
effect: NoSchedule
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ use_chat_completions_url_for_anthropic_messages: bool = bool(
|
|||
# config.yaml.
|
||||
strip_anthropic_total_tokens: bool = False
|
||||
anthropic_sse_ping_interval_seconds: float = 15.0
|
||||
sse_keepalive_ping_interval_seconds: float | None = None
|
||||
route_all_chat_openai_to_responses: bool = (
|
||||
os.getenv("LITELLM_ROUTE_ALL_CHAT_OPENAI_TO_RESPONSES", "false").lower() == "true"
|
||||
) # When True, routes all OpenAI /chat/completions requests through the Responses API bridge
|
||||
|
|
|
|||
|
|
@ -472,6 +472,8 @@ EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE: Final = float(
|
|||
### ANTHROPIC CONSTANTS ###
|
||||
ANTHROPIC_TOKEN_COUNTING_BETA_VERSION = os.getenv("ANTHROPIC_TOKEN_COUNTING_BETA_VERSION", "token-counting-2024-11-01")
|
||||
ANTHROPIC_SKILLS_API_BETA_VERSION: Final = "skills-2025-10-02"
|
||||
ANTHROPIC_BATCHES_ROUTE: Final = "/v1/messages/batches"
|
||||
VERTEX_BATCH_PREDICTION_JOBS_ROUTE: Final = "batchPredictionJobs"
|
||||
ANTHROPIC_WEB_SEARCH_TOOL_MAX_USES: Final = {
|
||||
"low": 1,
|
||||
"medium": 5,
|
||||
|
|
@ -1323,6 +1325,7 @@ LITELLM_METADATA_FIELD: Final = "litellm_metadata"
|
|||
OLD_LITELLM_METADATA_FIELD: Final = "metadata"
|
||||
RETURN_RAW_MODEL_NAME_METADATA_KEY: Final = "_complexity_router_return_raw_model_name"
|
||||
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY: Final = "_session_deployment_affinity_ttl"
|
||||
CONSUMED_REQUEST_TAGS_METADATA_KEY: Final = "_consumed_request_tags"
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
|
||||
LITELLM_TRUNCATED_PAYLOAD_FIELD: Final = "litellm_truncated"
|
||||
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE: Final = (
|
||||
|
|
@ -1527,6 +1530,10 @@ APSCHEDULER_REPLACE_EXISTING: Final = os.getenv("APSCHEDULER_REPLACE_EXISTING",
|
|||
"1",
|
||||
] # always replace existing jobs
|
||||
|
||||
# Width of the window scheduled background jobs are spread across, so they do not all fire
|
||||
# on one instant on every replica. Tunable per deployment via general_settings.
|
||||
DEFAULT_STAGGER_WINDOW_SECONDS: Final = 300
|
||||
|
||||
# The number of tag entries are higher than number of user, team entries. This leads to a higher QPS.
|
||||
# This will run tag spcific tasks at a later time to smooth QPS
|
||||
DAILY_TAG_SPEND_BATCH_MULTIPLIER: Final = 2.3
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import asyncio
|
|||
import base64
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from datetime import timedelta
|
||||
from typing import Any, Final, TypeVar
|
||||
|
||||
import httpx
|
||||
from mcp import ClientSession, ReadResourceResult, Resource, StdioServerParameters
|
||||
from mcp import ClientSession, McpError, ReadResourceResult, Resource, StdioServerParameters
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
|
|
@ -69,6 +70,29 @@ def _first_non_cancelled_cause(exc: BaseException) -> BaseException | None:
|
|||
return None
|
||||
|
||||
|
||||
_SDK_READ_TIMEOUT_CODE: Final = int(httpx.codes.REQUEST_TIMEOUT)
|
||||
"""The code the MCP SDK puts on its own elapsed read timeout, an HTTP status in a field that
|
||||
otherwise carries JSON-RPC error codes."""
|
||||
|
||||
|
||||
def _as_read_timeout(exc: BaseException) -> TimeoutError | None:
|
||||
"""The session read timeout elapsing, re-expressed as a ``TimeoutError``, or ``None``.
|
||||
|
||||
The SDK reports its own elapsed read timeout as ``McpError`` carrying an HTTP status code in a
|
||||
field that otherwise holds JSON-RPC error codes, and it relays an upstream's JSON-RPC error
|
||||
through that same class and field. The numeric code alone therefore cannot separate the two, and
|
||||
an upstream answering with application code 408 would be reported as a gateway timeout it never
|
||||
caused. The SDK raises its own from inside an ``except TimeoutError``, so the elapsed timeout is
|
||||
on the context chain, while a relayed error is built from a received message and has no such
|
||||
chain; that is the discriminator.
|
||||
"""
|
||||
if not isinstance(exc, McpError) or exc.error.code != _SDK_READ_TIMEOUT_CODE:
|
||||
return None
|
||||
if not isinstance(exc.__context__, TimeoutError):
|
||||
return None
|
||||
return TimeoutError(exc.error.message)
|
||||
|
||||
|
||||
TSessionResult = TypeVar("TSessionResult")
|
||||
|
||||
|
||||
|
|
@ -347,7 +371,14 @@ class MCPClient:
|
|||
session_kwargs["elicitation_callback"] = self._elicitation_callback
|
||||
if self._logging_callback is not None:
|
||||
session_kwargs["logging_callback"] = self._logging_callback
|
||||
session_ctx: Final = ClientSession(read_stream, write_stream, **session_kwargs)
|
||||
# The SDK drops a response stream that ends without a JSON-RPC reply, so nothing else
|
||||
# ever fails the request.
|
||||
session_ctx: Final = ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
read_timeout_seconds=timedelta(seconds=self.timeout),
|
||||
**session_kwargs,
|
||||
)
|
||||
session: Final = await session_ctx.__aenter__()
|
||||
try:
|
||||
init_result: Final = await session.initialize()
|
||||
|
|
@ -390,7 +421,16 @@ class MCPClient:
|
|||
self._last_initialize_instructions = None
|
||||
transport_ctx, http_client = self._create_transport_context()
|
||||
return await self._execute_session_operation(transport_ctx, operation)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
read_timeout: Final = _as_read_timeout(e)
|
||||
if read_timeout is not None:
|
||||
verbose_logger.warning(
|
||||
"MCP client timed out after %ss waiting for %s to answer; the server accepted the "
|
||||
"request and ended its response stream without a JSON-RPC reply",
|
||||
self.timeout,
|
||||
self.server_url or "stdio",
|
||||
)
|
||||
raise read_timeout from e
|
||||
_log: Final = verbose_logger.debug if quiet_on_error else verbose_logger.warning
|
||||
_log("MCP client run_with_session failed for %s", self.server_url or "stdio")
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# On success, logs events to Langfuse
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterable
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
|
|
@ -75,6 +75,22 @@ def _extract_cache_read_input_tokens(usage_obj) -> int:
|
|||
return cache_read_input_tokens
|
||||
|
||||
|
||||
def _as_steering_flag(value: object) -> bool:
|
||||
"""A string ``str_to_bool`` does not recognise falls back to its truthiness."""
|
||||
if isinstance(value, str):
|
||||
parsed: Final = str_to_bool(value)
|
||||
return bool(value) if parsed is None else parsed
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _as_steering_key_sequence(value: object) -> tuple[str, ...]:
|
||||
if isinstance(value, str):
|
||||
return tuple(key.strip() for key in value.split(",") if key.strip())
|
||||
if isinstance(value, Iterable):
|
||||
return tuple(str(key) for key in value)
|
||||
return ()
|
||||
|
||||
|
||||
def resolve_langfuse_credentials(
|
||||
langfuse_public_key=None,
|
||||
langfuse_secret=None,
|
||||
|
|
@ -552,10 +568,10 @@ class LangFuseLogger:
|
|||
# This allows continuing an existing trace while still returning the correct trace_id
|
||||
if existing_trace_id is not None:
|
||||
trace_id = existing_trace_id
|
||||
update_trace_keys: Final = cast(list, clean_metadata.pop("update_trace_keys", []))
|
||||
update_trace_keys: Final = _as_steering_key_sequence(clean_metadata.pop("update_trace_keys", ()))
|
||||
debug: Final = clean_metadata.pop("debug_langfuse", None)
|
||||
mask_input: Final = clean_metadata.pop("mask_input", False)
|
||||
mask_output: Final = clean_metadata.pop("mask_output", False)
|
||||
mask_input: Final = _as_steering_flag(clean_metadata.pop("mask_input", False))
|
||||
mask_output: Final = _as_steering_flag(clean_metadata.pop("mask_output", False))
|
||||
# Look for masking function in the dedicated location first (set by scrub_sensitive_keys_in_metadata)
|
||||
# Fall back to metadata for backwards compatibility
|
||||
masking_function: Final = litellm_params.get("_langfuse_masking_function") or clean_metadata.pop(
|
||||
|
|
|
|||
|
|
@ -90,7 +90,6 @@ class LangfuseOtelLogger(OpenTelemetry):
|
|||
"generation_name": LangfuseSpanAttributes.GENERATION_NAME,
|
||||
"generation_id": LangfuseSpanAttributes.GENERATION_ID,
|
||||
"parent_observation_id": LangfuseSpanAttributes.PARENT_OBSERVATION_ID,
|
||||
"version": LangfuseSpanAttributes.GENERATION_VERSION,
|
||||
"mask_input": LangfuseSpanAttributes.MASK_INPUT,
|
||||
"mask_output": LangfuseSpanAttributes.MASK_OUTPUT,
|
||||
"trace_user_id": LangfuseSpanAttributes.TRACE_USER_ID,
|
||||
|
|
@ -99,13 +98,18 @@ class LangfuseOtelLogger(OpenTelemetry):
|
|||
"trace_name": LangfuseSpanAttributes.TRACE_NAME,
|
||||
"trace_id": LangfuseSpanAttributes.TRACE_ID,
|
||||
"trace_metadata": LangfuseSpanAttributes.TRACE_METADATA,
|
||||
"trace_version": LangfuseSpanAttributes.TRACE_VERSION,
|
||||
"trace_release": LangfuseSpanAttributes.TRACE_RELEASE,
|
||||
"trace_release": LangfuseSpanAttributes.RELEASE,
|
||||
"existing_trace_id": LangfuseSpanAttributes.EXISTING_TRACE_ID,
|
||||
"update_trace_keys": LangfuseSpanAttributes.UPDATE_TRACE_KEYS,
|
||||
"debug_langfuse": LangfuseSpanAttributes.DEBUG_LANGFUSE,
|
||||
}
|
||||
|
||||
version: Final = (
|
||||
metadata.get("trace_version") if metadata.get("trace_version") is not None else metadata.get("version")
|
||||
)
|
||||
if version is not None:
|
||||
safe_set_attribute(span, LangfuseSpanAttributes.VERSION.value, version)
|
||||
|
||||
for key, enum_attr in mapping.items():
|
||||
if key in metadata and metadata[key] is not None:
|
||||
value = metadata[key]
|
||||
|
|
|
|||
|
|
@ -42,9 +42,7 @@ def langfuse_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str,
|
|||
public_key: Final = params.get("langfuse_public_key")
|
||||
secret_key: Final = params.get("langfuse_secret_key")
|
||||
if public_key and secret_key:
|
||||
return {
|
||||
"Authorization": _V1Langfuse._get_langfuse_authorization_header(
|
||||
public_key=public_key, secret_key=secret_key
|
||||
)
|
||||
}
|
||||
return _V1Langfuse._build_langfuse_otel_headers(
|
||||
_V1Langfuse._get_langfuse_authorization_header(public_key=public_key, secret_key=secret_key)
|
||||
)
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# What is this?
|
||||
## Helper utilities
|
||||
import copy
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
import httpx
|
||||
|
|
@ -181,7 +181,7 @@ def add_missing_spend_metadata_to_litellm_metadata(litellm_metadata: dict, metad
|
|||
|
||||
|
||||
def get_metadata_variable_name_from_kwargs(
|
||||
kwargs: dict,
|
||||
kwargs: Mapping[str, object],
|
||||
) -> Literal["metadata", "litellm_metadata"]:
|
||||
"""
|
||||
Helper to return what the "metadata" field should be called in the request data
|
||||
|
|
|
|||
|
|
@ -3462,7 +3462,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
model=self.model,
|
||||
messages=[],
|
||||
logging_obj=self,
|
||||
optional_params={},
|
||||
optional_params=self.optional_params or {},
|
||||
api_key="",
|
||||
request_data={},
|
||||
encoding=litellm.encoding,
|
||||
|
|
@ -3483,6 +3483,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
),
|
||||
model_response=litellm.ModelResponse(),
|
||||
json_mode=None,
|
||||
speed=self.optional_params.get("speed") if self.optional_params else None,
|
||||
)
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
Helper utilities for tracking the cost of built-in tools.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
import litellm
|
||||
|
|
@ -23,6 +24,14 @@ from litellm.types.utils import (
|
|||
)
|
||||
|
||||
|
||||
def _usage_reports_server_side_web_search_calls(usage: Usage) -> bool:
|
||||
details: Final = getattr(usage, "server_side_tool_usage_details", None)
|
||||
if not isinstance(details, Mapping):
|
||||
return False
|
||||
calls: Final = details.get("web_search_calls")
|
||||
return isinstance(calls, int) and calls > 0
|
||||
|
||||
|
||||
class StandardBuiltInToolCostTracking:
|
||||
"""
|
||||
Helper class for tracking the cost of built-in tools
|
||||
|
|
@ -351,6 +360,10 @@ class StandardBuiltInToolCostTracking:
|
|||
# and _handle_web_search_cost() is never called.
|
||||
if hasattr(usage, "server_tool_use") and _get_web_search_requests(usage.server_tool_use) is not None:
|
||||
return True
|
||||
# xAI reports usage.server_side_tool_usage_details.web_search_calls; a searched
|
||||
# answer with no url_citation annotations has no other chat-path signal
|
||||
if _usage_reports_server_side_web_search_calls(usage):
|
||||
return True
|
||||
return False
|
||||
elif isinstance(response_object, ResponsesAPIResponse):
|
||||
# response api explicitly includes web_search_call in the output
|
||||
|
|
@ -370,6 +383,8 @@ class StandardBuiltInToolCostTracking:
|
|||
)
|
||||
):
|
||||
return True
|
||||
if _usage_reports_server_side_web_search_calls(usage):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -432,7 +447,9 @@ class StandardBuiltInToolCostTracking:
|
|||
"""
|
||||
output: Final = response_object.output
|
||||
for output_item in output:
|
||||
_output_type: str | None = getattr(output_item, "type", None)
|
||||
_output_type: str | None = (
|
||||
output_item.get("type") if isinstance(output_item, dict) else getattr(output_item, "type", None)
|
||||
)
|
||||
if _output_type == output_type:
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -803,8 +803,28 @@ class ChunkProcessor:
|
|||
completion_tokens_details=completion_tokens_details,
|
||||
prompt_tokens_details=prompt_tokens_details,
|
||||
cost=cost,
|
||||
inference_geo=self._last_provider_pricing_field(chunks, "inference_geo"),
|
||||
speed=self._last_provider_pricing_field(chunks, "speed"),
|
||||
)
|
||||
|
||||
def _last_provider_pricing_field(
|
||||
self,
|
||||
chunks: Sequence["_UsageBearingChunk | ModelResponse"],
|
||||
field: str,
|
||||
) -> str | None:
|
||||
"""
|
||||
Last value of a provider-specific usage field that changes pricing but is not a
|
||||
declared ``Usage`` field, e.g. Anthropic's ``speed`` (fast mode multiplies
|
||||
non-cache token cost) and ``inference_geo``.
|
||||
"""
|
||||
values: Final = [
|
||||
value
|
||||
for chunk in chunks
|
||||
if (usage_chunk := self._extract_usage_chunk(chunk)) is not None
|
||||
and isinstance(value := getattr(usage_chunk, field, None), str)
|
||||
]
|
||||
return values[-1] if values else None
|
||||
|
||||
@staticmethod
|
||||
def _reset_anthropic_cursor_completion_tokens(
|
||||
chunks: Sequence["_UsageBearingChunk | ModelResponse"],
|
||||
|
|
@ -934,7 +954,16 @@ class ChunkProcessor:
|
|||
|
||||
# Return a new usage object with the new values
|
||||
|
||||
returned_usage = Usage(**returned_usage.model_dump())
|
||||
provider_pricing_fields: Final = {
|
||||
field: value
|
||||
for field, value in (
|
||||
("inference_geo", calculated_usage_per_chunk["inference_geo"]),
|
||||
("speed", calculated_usage_per_chunk["speed"]),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
|
||||
returned_usage = Usage(**returned_usage.model_dump(), **provider_pricing_fields)
|
||||
|
||||
return returned_usage
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,23 @@ Reference: https://build.nvidia.com/nvidia/llama-3_2-nv-rerankqa-1b-v2/deploy
|
|||
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.nvidia_nim.rerank.transformation import NvidiaNimRerankConfig
|
||||
from litellm.types.rerank import RerankResponse
|
||||
|
||||
|
||||
class NvidiaNimRankingConfig(NvidiaNimRerankConfig):
|
||||
"""
|
||||
Configuration for NVIDIA NIM models that use the /v1/ranking endpoint.
|
||||
|
||||
|
||||
The native /v1/ranking request schema accepts only 'model', 'query',
|
||||
'passages', and 'truncate' -- requests containing 'top_k' are rejected
|
||||
with a 400 validation error. Cohere-compatible 'top_n' is therefore
|
||||
applied client-side by truncating the converted response instead of
|
||||
being forwarded to the endpoint.
|
||||
|
||||
Example:
|
||||
curl -X "POST" 'https://ai.api.nvidia.com/v1/ranking' \
|
||||
-H 'Accept: application/json' \
|
||||
|
|
@ -27,6 +37,16 @@ class NvidiaNimRankingConfig(NvidiaNimRerankConfig):
|
|||
}'
|
||||
"""
|
||||
|
||||
SUPPORTED_PASSAGE_FIELDS: tuple[str, ...] = ("text", "image")
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# top_n captured in transform_rerank_request and applied in
|
||||
# transform_rerank_response. The provider config is instantiated
|
||||
# per-request (see ProviderConfigManager.get_provider_rerank_config),
|
||||
# so this does not leak across requests.
|
||||
self._client_side_top_n: int | None = None
|
||||
|
||||
def _get_clean_model_name(self, model: str) -> str:
|
||||
"""Strip 'nvidia_nim/' and 'ranking/' prefixes from model name."""
|
||||
# First strip nvidia_nim/ prefix if present
|
||||
|
|
@ -58,6 +78,47 @@ class NvidiaNimRankingConfig(NvidiaNimRerankConfig):
|
|||
|
||||
return f"{api_base}/v1/ranking"
|
||||
|
||||
def map_cohere_rerank_params(
|
||||
self,
|
||||
non_default_params: dict | None, # mutable-ok: matches BaseRerankConfig's request contract
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
query: str,
|
||||
documents: list[str | dict[str, object]], # mutable-ok: matches BaseRerankConfig's document contract
|
||||
custom_llm_provider: str | None = None,
|
||||
top_n: int | None = None,
|
||||
rank_fields: list[str] | None = None, # mutable-ok: matches BaseRerankConfig's field contract
|
||||
return_documents: bool | None = True,
|
||||
max_chunks_per_doc: int | None = None,
|
||||
max_tokens_per_doc: int | None = None,
|
||||
instruction: str | None = None,
|
||||
) -> dict: # mutable-ok: LiteLLM provider transforms return mutable request dictionaries
|
||||
"""
|
||||
Keep Cohere's top_n as-is instead of mapping it to top_k.
|
||||
|
||||
The native /v1/ranking endpoint rejects top_k, so top_n is applied
|
||||
client-side after the response is converted.
|
||||
"""
|
||||
optional_params: Final = super().map_cohere_rerank_params(
|
||||
non_default_params=non_default_params,
|
||||
model=model,
|
||||
drop_params=drop_params,
|
||||
query=query,
|
||||
documents=documents,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
top_n=None, # do not map top_n -> top_k for /v1/ranking
|
||||
rank_fields=rank_fields,
|
||||
return_documents=return_documents,
|
||||
max_chunks_per_doc=max_chunks_per_doc,
|
||||
max_tokens_per_doc=max_tokens_per_doc,
|
||||
instruction=instruction,
|
||||
)
|
||||
# /v1/ranking rejects top_k even when passed as a provider-specific param
|
||||
optional_params.pop("top_k", None)
|
||||
if top_n is not None:
|
||||
optional_params["top_n"] = top_n
|
||||
return optional_params
|
||||
|
||||
def transform_rerank_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -67,11 +128,66 @@ class NvidiaNimRankingConfig(NvidiaNimRerankConfig):
|
|||
) -> dict:
|
||||
"""
|
||||
Transform request, using clean model name without 'ranking/' prefix.
|
||||
|
||||
top_n / top_k are stripped from the outgoing request: the native
|
||||
/v1/ranking endpoint accepts only model, query, passages, and
|
||||
truncate. top_n is stashed and applied client-side in
|
||||
transform_rerank_response.
|
||||
"""
|
||||
top_n: Final = optional_rerank_params.get("top_n")
|
||||
if top_n is not None:
|
||||
if isinstance(top_n, bool) or not isinstance(top_n, int) or top_n < 1:
|
||||
raise ValueError(f"top_n must be a positive integer, got: {top_n!r}")
|
||||
self._client_side_top_n = top_n
|
||||
|
||||
clean_model: Final = self._get_clean_model_name(model)
|
||||
filtered_params: Final = { # mutable-ok: the base transformer requires a mutable request dictionary
|
||||
k: v for k, v in optional_rerank_params.items() if k not in ("top_n", "top_k")
|
||||
}
|
||||
return super().transform_rerank_request(
|
||||
model=clean_model,
|
||||
optional_rerank_params=optional_rerank_params,
|
||||
optional_rerank_params=filtered_params,
|
||||
headers=headers,
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
def transform_rerank_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
model_response: RerankResponse,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
api_key: str | None = None,
|
||||
request_data: dict | None = None, # mutable-ok: matches BaseRerankConfig's response contract
|
||||
optional_params: dict | None = None, # mutable-ok: matches BaseRerankConfig's response contract
|
||||
litellm_params: dict | None = None, # mutable-ok: matches BaseRerankConfig's response contract
|
||||
) -> RerankResponse:
|
||||
"""
|
||||
Convert the native ranking response, then apply top_n client-side.
|
||||
|
||||
/v1/ranking returns rankings sorted by relevance, but sort before
|
||||
truncating in case a server returns them unsorted.
|
||||
"""
|
||||
resolved_request_data: Final = request_data or {} # mutable-ok: the base transformer requires a dictionary
|
||||
resolved_optional_params: Final = optional_params or {} # mutable-ok: response options are keyed lookups
|
||||
resolved_litellm_params: Final = litellm_params or {} # mutable-ok: the base transformer requires a dictionary
|
||||
|
||||
response: Final = super().transform_rerank_response(
|
||||
model=model,
|
||||
raw_response=raw_response,
|
||||
model_response=model_response,
|
||||
logging_obj=logging_obj,
|
||||
api_key=api_key,
|
||||
request_data=resolved_request_data,
|
||||
optional_params=resolved_optional_params,
|
||||
litellm_params=resolved_litellm_params,
|
||||
)
|
||||
|
||||
top_n: Final = resolved_optional_params.get("top_n") or self._client_side_top_n
|
||||
if top_n is not None and response.results is not None and len(response.results) > top_n:
|
||||
response.results = sorted(
|
||||
response.results,
|
||||
key=lambda result: result["relevance_score"],
|
||||
reverse=True,
|
||||
)[:top_n]
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ class NvidiaNimQueryObject(TypedDict):
|
|||
text: Required[str]
|
||||
|
||||
|
||||
class NvidiaNimPassageObject(TypedDict):
|
||||
text: Required[str]
|
||||
class NvidiaNimPassageObject(TypedDict, total=False):
|
||||
text: str
|
||||
image: str
|
||||
|
||||
|
||||
class NvidiaNimRerankRequest(TypedDict, total=False):
|
||||
|
|
@ -53,6 +54,10 @@ class NvidiaNimRerankConfig(BaseRerankConfig):
|
|||
|
||||
DEFAULT_NIM_RERANK_API_BASE = "https://ai.api.nvidia.com"
|
||||
|
||||
# The legacy retrieval rerank route accepts text passages only. The native
|
||||
# ranking subclass expands this tuple for VL models that accept images.
|
||||
SUPPORTED_PASSAGE_FIELDS: tuple[str, ...] = ("text",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
|
|
@ -206,11 +211,17 @@ class NvidiaNimRerankConfig(BaseRerankConfig):
|
|||
if isinstance(doc, str):
|
||||
passages.append({"text": doc})
|
||||
elif isinstance(doc, dict):
|
||||
# If document is already a dict, check if it has 'text' field
|
||||
if "text" in doc:
|
||||
passages.append({"text": doc["text"]})
|
||||
# Preserve only the structured passage fields supported by the
|
||||
# selected rerank route.
|
||||
supported_fields: NvidiaNimPassageObject = {} # mutable-ok: assembling a request TypedDict
|
||||
if "text" in self.SUPPORTED_PASSAGE_FIELDS and "text" in doc:
|
||||
supported_fields["text"] = doc["text"]
|
||||
if "image" in self.SUPPORTED_PASSAGE_FIELDS and "image" in doc:
|
||||
supported_fields["image"] = doc["image"]
|
||||
if supported_fields:
|
||||
passages.append(supported_fields)
|
||||
else:
|
||||
# Otherwise, stringify the dict
|
||||
# No supported fields - stringify the dict
|
||||
import json
|
||||
|
||||
passages.append({"text": json.dumps(doc)})
|
||||
|
|
@ -304,9 +315,10 @@ class NvidiaNimRerankConfig(BaseRerankConfig):
|
|||
"relevance_score": ranking["logit"],
|
||||
}
|
||||
|
||||
# Include document if it was in the original request
|
||||
# Include document if it was in the original request.
|
||||
# Image-only passages carry no 'text' field, so guard the lookup.
|
||||
index: int = ranking["index"]
|
||||
if index < len(original_passages):
|
||||
if index < len(original_passages) and "text" in original_passages[index]:
|
||||
result_item["document"] = {"text": original_passages[index]["text"]}
|
||||
|
||||
results.append(result_item)
|
||||
|
|
|
|||
|
|
@ -353,6 +353,16 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
)
|
||||
return event_pydantic_model.model_construct(**parsed_chunk)
|
||||
|
||||
@staticmethod
|
||||
def parse_terminal_response_from_stream_chunks(all_chunks: list[str]) -> ResponsesAPIResponse | None:
|
||||
for chunk_str in reversed(all_chunks):
|
||||
for event_model in (ResponseCompletedEvent, ResponseIncompleteEvent, ResponseFailedEvent):
|
||||
try:
|
||||
return event_model.model_validate_json(chunk_str.removeprefix("data: ")).response
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_event_model_class(event_type: str) -> Any:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ from typing import Any, Final, cast
|
|||
import httpx
|
||||
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
HTTPHandler,
|
||||
_get_httpx_client,
|
||||
)
|
||||
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
|
@ -123,6 +128,79 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
|
||||
return response_json["predictions"]
|
||||
|
||||
@staticmethod
|
||||
def _sync_post(
|
||||
client: HTTPHandler | httpx.Client | None,
|
||||
api_base: str,
|
||||
headers: dict[str, str], # mutable-ok: forwarded to post(headers: dict | None)
|
||||
request_data: dict[str, Any], # mutable-ok: forwarded to post(json: dict | ...)
|
||||
timeout: float | httpx.Timeout | None,
|
||||
) -> httpx.Response:
|
||||
if isinstance(client, HTTPHandler):
|
||||
return client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
timeout=timeout,
|
||||
)
|
||||
if isinstance(client, httpx.Client):
|
||||
if timeout is None:
|
||||
return client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
)
|
||||
return client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
timeout=timeout,
|
||||
)
|
||||
return _get_httpx_client().post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _async_post(
|
||||
client: AsyncHTTPHandler | httpx.AsyncClient | None,
|
||||
api_base: str,
|
||||
headers: dict[str, str], # mutable-ok: forwarded to post(headers: dict | None)
|
||||
request_data: dict[str, Any], # mutable-ok: forwarded to post(json: dict | ...)
|
||||
timeout: float | httpx.Timeout | None,
|
||||
) -> httpx.Response:
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
if isinstance(client, AsyncHTTPHandler):
|
||||
return await client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
timeout=timeout,
|
||||
)
|
||||
if isinstance(client, httpx.AsyncClient):
|
||||
if timeout is None:
|
||||
return await client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
)
|
||||
return await client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
timeout=timeout,
|
||||
)
|
||||
return await get_async_httpx_client(llm_provider=LlmProviders.VERTEX_AI).post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def completion(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -137,7 +215,7 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
acompletion: bool,
|
||||
litellm_params: dict,
|
||||
logger_fn: Callable | None = None,
|
||||
client: httpx.Client | None = None,
|
||||
client: HTTPHandler | AsyncHTTPHandler | httpx.Client | httpx.AsyncClient | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
encoding=None,
|
||||
custom_llm_provider: str = "vertex_ai",
|
||||
|
|
@ -147,6 +225,7 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
Supports both sync and async requests with fake streaming.
|
||||
"""
|
||||
if acompletion:
|
||||
async_client = client if isinstance(client, (AsyncHTTPHandler, httpx.AsyncClient)) else None
|
||||
return self._async_completion(
|
||||
model=model,
|
||||
messages=messages,
|
||||
|
|
@ -157,10 +236,12 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
logging_obj=logging_obj,
|
||||
optional_params=optional_params,
|
||||
litellm_params=litellm_params,
|
||||
client=async_client,
|
||||
timeout=timeout,
|
||||
encoding=encoding,
|
||||
)
|
||||
else:
|
||||
sync_client = client if isinstance(client, (HTTPHandler, httpx.Client)) else None
|
||||
return self._sync_completion(
|
||||
model=model,
|
||||
messages=messages,
|
||||
|
|
@ -171,6 +252,7 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
logging_obj=logging_obj,
|
||||
optional_params=optional_params,
|
||||
litellm_params=litellm_params,
|
||||
client=sync_client,
|
||||
timeout=timeout,
|
||||
encoding=encoding,
|
||||
)
|
||||
|
|
@ -186,11 +268,11 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
logging_obj: Any,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
timeout: float | httpx.Timeout | None,
|
||||
encoding: Any,
|
||||
client: HTTPHandler | httpx.Client | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
encoding: Any = None,
|
||||
):
|
||||
"""Synchronous completion request"""
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
from litellm.utils import convert_to_model_response_object
|
||||
|
||||
# Check if streaming is requested (will be faked)
|
||||
|
|
@ -222,11 +304,11 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
)
|
||||
|
||||
# Make the HTTP request
|
||||
http_handler: Final = HTTPHandler(concurrent_limit=1)
|
||||
response: Final = http_handler.post(
|
||||
url=api_base,
|
||||
response: Final = self._sync_post(
|
||||
client=client,
|
||||
api_base=api_base,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
request_data=request_data,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
|
@ -276,12 +358,11 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
logging_obj: Any,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
timeout: float | httpx.Timeout | None,
|
||||
encoding: Any,
|
||||
client: AsyncHTTPHandler | httpx.AsyncClient | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
encoding: Any = None,
|
||||
):
|
||||
"""Asynchronous completion request"""
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.utils import convert_to_model_response_object
|
||||
|
||||
# Check if streaming is requested (will be faked)
|
||||
|
|
@ -313,13 +394,11 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
)
|
||||
|
||||
# Make the HTTP request
|
||||
http_handler: Final = get_async_httpx_client(
|
||||
llm_provider=LlmProviders.VERTEX_AI,
|
||||
)
|
||||
response: Final = await http_handler.post(
|
||||
url=api_base,
|
||||
response: Final = await self._async_post(
|
||||
client=client,
|
||||
api_base=api_base,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
request_data=request_data,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from collections.abc import AsyncIterator, Iterator
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -12,13 +12,15 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
strip_name_from_messages,
|
||||
)
|
||||
from litellm.llms.xai.common_utils import XAIModelInfo
|
||||
from litellm.llms.xai.cost_calculator import (
|
||||
apply_server_side_tool_usage_details_to_usage,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import (
|
||||
Choices,
|
||||
ModelResponse,
|
||||
ModelResponseStream,
|
||||
PromptTokensDetailsWrapper,
|
||||
Usage,
|
||||
)
|
||||
|
||||
|
|
@ -248,7 +250,7 @@ class XAIChatConfig(OpenAIGPTConfig):
|
|||
XAI API returns empty string for finish_reason when using tools,
|
||||
so we need to fix this after the standard OpenAI transformation.
|
||||
|
||||
Also handles X.AI web search usage tracking by extracting num_sources_used.
|
||||
Also handles X.AI web search usage tracking.
|
||||
"""
|
||||
|
||||
# First, let the parent class handle the standard transformation
|
||||
|
|
@ -351,25 +353,20 @@ class XAIChatConfig(OpenAIGPTConfig):
|
|||
|
||||
def _enhance_usage_with_xai_web_search_fields(self, model_response: ModelResponse, raw_response_json: dict) -> None:
|
||||
"""
|
||||
Extract num_sources_used from X.AI response and map it to web_search_requests.
|
||||
Copy usage.server_side_tool_usage_details from the provider usage block
|
||||
onto model_response.usage for tool cost calculation.
|
||||
"""
|
||||
if not hasattr(model_response, "usage") or model_response.usage is None:
|
||||
return
|
||||
|
||||
usage: Final[Usage] = model_response.usage
|
||||
num_sources_used = None
|
||||
response_usage: Final = raw_response_json.get("usage", {})
|
||||
if isinstance(response_usage, dict) and "num_sources_used" in response_usage:
|
||||
num_sources_used = response_usage.get("num_sources_used")
|
||||
|
||||
# Map num_sources_used to web_search_requests for cost detection
|
||||
if num_sources_used is not None and num_sources_used > 0:
|
||||
if usage.prompt_tokens_details is None:
|
||||
usage.prompt_tokens_details = PromptTokensDetailsWrapper()
|
||||
|
||||
usage.prompt_tokens_details.web_search_requests = int(num_sources_used)
|
||||
setattr(usage, "num_sources_used", int(num_sources_used))
|
||||
verbose_logger.debug("X.AI web search sources used: %s", num_sources_used)
|
||||
response_usage: Final = raw_response_json.get("usage")
|
||||
if not isinstance(response_usage, dict):
|
||||
return
|
||||
details: Final = response_usage.get("server_side_tool_usage_details")
|
||||
if isinstance(details, Mapping):
|
||||
apply_server_side_tool_usage_details_to_usage(usage, details)
|
||||
verbose_logger.debug("X.AI server_side_tool_usage_details: %s", details)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_openai_compatible_usage_totals(
|
||||
|
|
|
|||
|
|
@ -4,14 +4,37 @@ Helper util for handling XAI-specific cost calculation
|
|||
- Handles XAI-specific reasoning token billing (billed as part of completion tokens)
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
|
||||
from litellm.types.utils import Usage
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.utils import ModelInfo
|
||||
|
||||
# https://docs.x.ai/developers/pricing#tools-pricing — default when unset in model map
|
||||
_DEFAULT_WEB_SEARCH_COST_PER_CALL: Final = 5.0 / 1000.0
|
||||
|
||||
|
||||
def apply_server_side_tool_usage_details_to_usage(usage: Usage, details: Mapping[str, object] | None) -> None:
|
||||
"""
|
||||
Attach server_side_tool_usage_details and mirror web_search_calls onto
|
||||
prompt_tokens_details.web_search_requests for built-in tool cost gating.
|
||||
"""
|
||||
if details is None:
|
||||
return
|
||||
usage.server_side_tool_usage_details = details # pyright: ignore[reportAttributeAccessIssue] # extra # rebind-ok: extras
|
||||
try:
|
||||
web_search_calls: Final = int(details.get("web_search_calls") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if web_search_calls <= 0:
|
||||
return
|
||||
prompt_tokens_details: Final = usage.prompt_tokens_details or PromptTokensDetailsWrapper()
|
||||
prompt_tokens_details.web_search_requests = web_search_calls
|
||||
usage.prompt_tokens_details = prompt_tokens_details # rebind-ok: write details onto caller usage
|
||||
|
||||
|
||||
def cost_per_token(model: str, usage: Usage) -> tuple[float, float]:
|
||||
"""
|
||||
|
|
@ -32,9 +55,11 @@ def cost_per_token(model: str, usage: Usage) -> tuple[float, float]:
|
|||
prompt_tokens: Final = int(getattr(usage, "prompt_tokens", 0) or 0)
|
||||
completion_tokens: Final = int(getattr(usage, "completion_tokens", 0) or 0)
|
||||
total_tokens: Final = int(getattr(usage, "total_tokens", 0) or 0)
|
||||
reasoning_tokens = 0
|
||||
if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details:
|
||||
reasoning_tokens = int(getattr(usage.completion_tokens_details, "reasoning_tokens", 0) or 0)
|
||||
reasoning_tokens: Final = (
|
||||
int(getattr(usage.completion_tokens_details, "reasoning_tokens", 0) or 0)
|
||||
if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details
|
||||
else 0
|
||||
)
|
||||
|
||||
already_normalised: Final = total_tokens == prompt_tokens + completion_tokens
|
||||
total_completion_tokens: Final = completion_tokens if already_normalised else completion_tokens + reasoning_tokens
|
||||
|
|
@ -52,33 +77,48 @@ def cost_per_token(model: str, usage: Usage) -> tuple[float, float]:
|
|||
return prompt_cost, completion_cost
|
||||
|
||||
|
||||
def _web_search_cost_per_call_from_model_info(model_info: "ModelInfo") -> float:
|
||||
"""
|
||||
Per-invocation web_search price from model_info when configured.
|
||||
|
||||
Prefer ``search_context_cost_per_query`` (same shape as Gemini/Anthropic web
|
||||
search pricing in the model cost map). Fall back to current xAI list pricing.
|
||||
"""
|
||||
search_costs: Final = model_info.get("search_context_cost_per_query")
|
||||
if not isinstance(search_costs, Mapping):
|
||||
return _DEFAULT_WEB_SEARCH_COST_PER_CALL
|
||||
for key in (
|
||||
"search_context_size_medium",
|
||||
"search_context_size_low",
|
||||
"search_context_size_high",
|
||||
):
|
||||
value = search_costs.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
cost = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if cost > 0:
|
||||
return cost
|
||||
return _DEFAULT_WEB_SEARCH_COST_PER_CALL
|
||||
|
||||
|
||||
def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> float:
|
||||
"""
|
||||
Calculate the cost of web search requests for X.AI models.
|
||||
|
||||
X.AI Live Search costs $25 per 1,000 sources used.
|
||||
Each source costs $0.025.
|
||||
|
||||
The number of sources is stored in prompt_tokens_details.web_search_requests
|
||||
by the transformation layer to be compatible with the existing detection system.
|
||||
Counts invocations from usage.server_side_tool_usage_details.web_search_calls.
|
||||
Per-call rate comes from model_info.search_context_cost_per_query when set,
|
||||
otherwise the default xAI tools rate ($5 / 1k calls).
|
||||
"""
|
||||
# Cost per source used: $25 per 1,000 sources = $0.025 per source
|
||||
cost_per_source: Final = 25.0 / 1000.0 # $0.025
|
||||
|
||||
num_sources_used = 0
|
||||
|
||||
if (
|
||||
hasattr(usage, "prompt_tokens_details")
|
||||
and usage.prompt_tokens_details is not None
|
||||
and hasattr(usage.prompt_tokens_details, "web_search_requests")
|
||||
and usage.prompt_tokens_details.web_search_requests is not None
|
||||
):
|
||||
num_sources_used = int(usage.prompt_tokens_details.web_search_requests)
|
||||
|
||||
# Fallback: try to get from num_sources_used if set directly
|
||||
elif hasattr(usage, "num_sources_used") and usage.num_sources_used is not None:
|
||||
num_sources_used = int(usage.num_sources_used)
|
||||
|
||||
total_cost: Final = cost_per_source * num_sources_used
|
||||
|
||||
return total_cost
|
||||
details: Final = getattr(usage, "server_side_tool_usage_details", None)
|
||||
if not isinstance(details, Mapping):
|
||||
return 0.0
|
||||
try:
|
||||
web_search_calls: Final = int(details.get("web_search_calls") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
if web_search_calls <= 0:
|
||||
return 0.0
|
||||
return _web_search_cost_per_call_from_model_info(model_info) * web_search_calls
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import TYPE_CHECKING, Any, Final
|
||||
from typing import Any, Final
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -12,13 +12,6 @@ from litellm.types.llms.xai import XAIWebSearchTool, XAIXSearchTool
|
|||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
|
||||
LiteLLMLoggingObj = _LiteLLMLoggingObj
|
||||
else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -9707,6 +9707,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -9830,6 +9831,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -9922,6 +9924,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -10007,6 +10010,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -10410,6 +10414,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -10624,6 +10629,7 @@
|
|||
"output_cost_per_token": 1.85e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -10701,6 +10707,7 @@
|
|||
"output_cost_per_token": 1.85e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -11262,6 +11269,7 @@
|
|||
"output_cost_per_token": 1.85e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -12940,6 +12948,103 @@
|
|||
"supports_system_messages": true,
|
||||
"supports_tool_choice": false
|
||||
},
|
||||
"dashscope/deepseek-v4-flash": {
|
||||
"cache_read_input_token_cost": 4e-08,
|
||||
"input_cost_per_token": 2e-07,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 393216,
|
||||
"max_tokens": 393216,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-07,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"dashscope/deepseek-v4-flash-0731": {
|
||||
"cache_read_input_token_cost": 4e-08,
|
||||
"input_cost_per_token": 2e-07,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 393216,
|
||||
"max_tokens": 393216,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-07,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"dashscope/deepseek-v4-pro": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"input_cost_per_token": 2.4e-06,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 393216,
|
||||
"max_tokens": 393216,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.8e-06,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"dashscope/glm-5.1": {
|
||||
"cache_read_input_token_cost": 2.6e-07,
|
||||
"input_cost_per_token": 1.4e-06,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 202745,
|
||||
"max_output_tokens": 131072,
|
||||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.4e-06,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"dashscope/glm-5.2": {
|
||||
"cache_read_input_token_cost": 2.8e-07,
|
||||
"input_cost_per_token": 1.4e-06,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 131072,
|
||||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.4e-06,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"dashscope/kimi-k2.7-code": {
|
||||
"cache_read_input_token_cost": 1.9e-07,
|
||||
"input_cost_per_token": 9.5e-07,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 229376,
|
||||
"max_output_tokens": 16384,
|
||||
"max_tokens": 16384,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-06,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"dashscope/qwen-coder": {
|
||||
"input_cost_per_token": 3e-07,
|
||||
"litellm_provider": "dashscope",
|
||||
|
|
@ -13733,6 +13838,23 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"dashscope/qwen3.8-max": {
|
||||
"cache_read_input_token_cost": 2.5e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 991808,
|
||||
"max_output_tokens": 131072,
|
||||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-06,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"dashscope/qwq-plus": {
|
||||
"input_cost_per_token": 8e-07,
|
||||
"litellm_provider": "dashscope",
|
||||
|
|
@ -15430,6 +15552,17 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_function_calling": true
|
||||
},
|
||||
"deepinfra/nvidia/NVIDIA-Nemotron-3.5-Lightning": {
|
||||
"max_input_tokens": 262144,
|
||||
"input_cost_per_token": 5e-08,
|
||||
"output_cost_per_token": 2e-07,
|
||||
"litellm_provider": "deepinfra",
|
||||
"mode": "chat",
|
||||
"source": "https://deepinfra.com/nvidia/NVIDIA-Nemotron-3.5-Lightning",
|
||||
"supports_tool_choice": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"deepinfra/nvidia/NVIDIA-Nemotron-Nano-9B-v2": {
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -18888,6 +19021,60 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"vertex_ai/gemini-3.7-flash": {
|
||||
"cache_read_input_token_cost": 7.5e-08,
|
||||
"cache_read_input_token_cost_flex": 3.75e-08,
|
||||
"input_cost_per_token": 7.5e-07,
|
||||
"input_cost_per_token_batches": 3.75e-07,
|
||||
"input_cost_per_token_flex": 3.75e-07,
|
||||
"litellm_provider": "vertex_ai",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_reasoning_token": 3.75e-06,
|
||||
"output_cost_per_token": 3.75e-06,
|
||||
"output_cost_per_token_batches": 1.875e-06,
|
||||
"output_cost_per_token_flex": 1.875e-06,
|
||||
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_input": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_url_context": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"supports_native_streaming": true,
|
||||
"input_cost_per_token_priority": 1.35e-06,
|
||||
"output_cost_per_token_priority": 6.75e-06,
|
||||
"cache_read_input_token_cost_priority": 1.35e-07,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_low": 0.014,
|
||||
"search_context_size_medium": 0.014,
|
||||
"search_context_size_high": 0.014
|
||||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"vertex_ai/gemini-3.1-pro-preview": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
|
||||
|
|
@ -20563,6 +20750,63 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini/gemini-3.7-flash": {
|
||||
"cache_read_input_token_cost": 7.5e-08,
|
||||
"cache_read_input_token_cost_flex": 3.75e-08,
|
||||
"input_cost_per_token": 7.5e-07,
|
||||
"input_cost_per_token_batches": 3.75e-07,
|
||||
"input_cost_per_token_flex": 3.75e-07,
|
||||
"litellm_provider": "gemini",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_reasoning_token": 3.75e-06,
|
||||
"output_cost_per_token": 3.75e-06,
|
||||
"output_cost_per_token_batches": 1.875e-06,
|
||||
"output_cost_per_token_flex": 1.875e-06,
|
||||
"rpm": 2000,
|
||||
"source": "https://ai.google.dev/pricing/gemini-3",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_output": false,
|
||||
"supports_audio_input": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_url_context": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"supports_native_streaming": true,
|
||||
"tpm": 800000,
|
||||
"input_cost_per_token_priority": 1.35e-06,
|
||||
"output_cost_per_token_priority": 6.75e-06,
|
||||
"cache_read_input_token_cost_priority": 1.35e-07,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_low": 0.014,
|
||||
"search_context_size_medium": 0.014,
|
||||
"search_context_size_high": 0.014
|
||||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini/gemini-omni-flash-preview": {
|
||||
"input_cost_per_audio_token": 1.5e-06,
|
||||
"input_cost_per_token": 1.5e-06,
|
||||
|
|
@ -20898,6 +21142,61 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini-3.7-flash": {
|
||||
"cache_read_input_token_cost": 7.5e-08,
|
||||
"cache_read_input_token_cost_flex": 3.75e-08,
|
||||
"input_cost_per_token": 7.5e-07,
|
||||
"input_cost_per_token_batches": 3.75e-07,
|
||||
"input_cost_per_token_flex": 3.75e-07,
|
||||
"litellm_provider": "vertex_ai-language-models",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_reasoning_token": 3.75e-06,
|
||||
"output_cost_per_token": 3.75e-06,
|
||||
"output_cost_per_token_batches": 1.875e-06,
|
||||
"output_cost_per_token_flex": 1.875e-06,
|
||||
"source": "https://ai.google.dev/pricing/gemini-3",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_output": false,
|
||||
"supports_audio_input": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_url_context": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"supports_native_streaming": true,
|
||||
"input_cost_per_token_priority": 1.35e-06,
|
||||
"output_cost_per_token_priority": 6.75e-06,
|
||||
"cache_read_input_token_cost_priority": 1.35e-07,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_low": 0.014,
|
||||
"search_context_size_medium": 0.014,
|
||||
"search_context_size_high": 0.014
|
||||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini/gemini-2.5-pro-preview-tts": {
|
||||
"cache_read_input_token_cost": 1.25e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
|
||||
|
|
@ -25987,11 +26286,12 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"groq/llama-3.1-8b-instant": {
|
||||
"deprecation_date": "2026-08-16",
|
||||
"input_cost_per_token": 5e-08,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 8192,
|
||||
"max_tokens": 8192,
|
||||
"max_input_tokens": 131072,
|
||||
"max_output_tokens": 131072,
|
||||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 8e-08,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -25999,9 +26299,10 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"groq/llama-3.3-70b-versatile": {
|
||||
"deprecation_date": "2026-08-16",
|
||||
"input_cost_per_token": 5.9e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 128000,
|
||||
"max_input_tokens": 131072,
|
||||
"max_output_tokens": 32768,
|
||||
"max_tokens": 32768,
|
||||
"mode": "chat",
|
||||
|
|
@ -26022,7 +26323,28 @@
|
|||
"supports_response_schema": false,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"groq/meta-llama/llama-prompt-guard-2-22m": {
|
||||
"input_cost_per_token": 3e-08,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 512,
|
||||
"max_output_tokens": 512,
|
||||
"max_tokens": 512,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-08,
|
||||
"source": "https://console.groq.com/docs/models"
|
||||
},
|
||||
"groq/meta-llama/llama-prompt-guard-2-86m": {
|
||||
"input_cost_per_token": 4e-08,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 512,
|
||||
"max_output_tokens": 512,
|
||||
"max_tokens": 512,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-08,
|
||||
"source": "https://console.groq.com/docs/model/meta-llama/llama-prompt-guard-2-86m"
|
||||
},
|
||||
"groq/meta-llama/llama-guard-4-12b": {
|
||||
"deprecation_date": "2026-03-05",
|
||||
"input_cost_per_token": 2e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 8192,
|
||||
|
|
@ -26032,6 +26354,7 @@
|
|||
"output_cost_per_token": 2e-07
|
||||
},
|
||||
"groq/meta-llama/llama-4-maverick-17b-128e-instruct": {
|
||||
"deprecation_date": "2026-03-09",
|
||||
"input_cost_per_token": 2e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -26045,6 +26368,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"groq/meta-llama/llama-4-scout-17b-16e-instruct": {
|
||||
"deprecation_date": "2026-07-17",
|
||||
"input_cost_per_token": 1.1e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -26058,6 +26382,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"groq/moonshotai/kimi-k2-instruct-0905": {
|
||||
"deprecation_date": "2026-04-15",
|
||||
"input_cost_per_token": 1e-06,
|
||||
"output_cost_per_token": 3e-06,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -26075,8 +26400,8 @@
|
|||
"input_cost_per_token": 1.5e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131072,
|
||||
"max_output_tokens": 32766,
|
||||
"max_tokens": 32766,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-07,
|
||||
"search_context_cost_per_query": {
|
||||
|
|
@ -26096,8 +26421,8 @@
|
|||
"input_cost_per_token": 7.5e-08,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131072,
|
||||
"max_output_tokens": 32768,
|
||||
"max_tokens": 32768,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-07,
|
||||
"search_context_cost_per_query": {
|
||||
|
|
@ -26132,7 +26457,26 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"groq/canopylabs/orpheus-v1-english": {
|
||||
"input_cost_per_character": 2.2e-05,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 4000,
|
||||
"max_output_tokens": 50000,
|
||||
"max_tokens": 50000,
|
||||
"mode": "audio_speech",
|
||||
"source": "https://console.groq.com/docs/model/canopylabs/orpheus-v1-english"
|
||||
},
|
||||
"groq/canopylabs/orpheus-arabic-saudi": {
|
||||
"input_cost_per_character": 4e-05,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 4000,
|
||||
"max_output_tokens": 50000,
|
||||
"max_tokens": 50000,
|
||||
"mode": "audio_speech",
|
||||
"source": "https://console.groq.com/docs/models"
|
||||
},
|
||||
"groq/playai-tts": {
|
||||
"deprecation_date": "2025-12-31",
|
||||
"input_cost_per_character": 5e-05,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 10000,
|
||||
|
|
@ -26140,7 +26484,23 @@
|
|||
"max_tokens": 10000,
|
||||
"mode": "audio_speech"
|
||||
},
|
||||
"groq/qwen/qwen3.6-27b": {
|
||||
"input_cost_per_token": 6e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131072,
|
||||
"max_output_tokens": 16384,
|
||||
"max_tokens": 16384,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-06,
|
||||
"source": "https://console.groq.com/docs/model/qwen/qwen3.6-27b",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"groq/qwen/qwen3-32b": {
|
||||
"deprecation_date": "2026-07-17",
|
||||
"input_cost_per_token": 2.9e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131000,
|
||||
|
|
@ -31632,6 +31992,17 @@
|
|||
"supports_video_input": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"openrouter/nvidia/nemotron-3.5-lightning": {
|
||||
"input_cost_per_token": 5e-08,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 262144,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2e-07,
|
||||
"source": "https://openrouter.ai/nvidia/nemotron-3.5-lightning",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"openrouter/openai/gpt-3.5-turbo": {
|
||||
"input_cost_per_token": 1.5e-06,
|
||||
"litellm_provider": "openrouter",
|
||||
|
|
@ -35769,6 +36140,7 @@
|
|||
"output_cost_per_token": 1.85e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"eu.deepseek.v3.2": {
|
||||
|
|
@ -35781,6 +36153,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"us.meta.llama3-1-405b-instruct-v1:0": {
|
||||
|
|
@ -45680,11 +46053,15 @@
|
|||
},
|
||||
"bedrock_mantle/openai.gpt-5.6-sol": {
|
||||
"input_cost_per_token": 5.5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1.1e-05,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.375e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
|
||||
"output_cost_per_token": 3.3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.95e-05,
|
||||
"litellm_provider": "bedrock_mantle",
|
||||
"max_input_tokens": 272000,
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "responses",
|
||||
|
|
@ -45708,11 +46085,15 @@
|
|||
},
|
||||
"bedrock_mantle/openai.gpt-5.6-terra": {
|
||||
"input_cost_per_token": 2.2e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 4.4e-06,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5.5e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4.4e-07,
|
||||
"output_cost_per_token": 1.32e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 1.98e-05,
|
||||
"litellm_provider": "bedrock_mantle",
|
||||
"max_input_tokens": 272000,
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "responses",
|
||||
|
|
@ -45736,11 +46117,15 @@
|
|||
},
|
||||
"bedrock_mantle/openai.gpt-5.6-luna": {
|
||||
"input_cost_per_token": 2.2e-07,
|
||||
"input_cost_per_token_above_272k_tokens": 4.4e-07,
|
||||
"cache_creation_input_token_cost": 2.75e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5.5e-07,
|
||||
"cache_read_input_token_cost": 2.2e-08,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4.4e-08,
|
||||
"output_cost_per_token": 1.32e-06,
|
||||
"output_cost_per_token_above_272k_tokens": 1.98e-06,
|
||||
"litellm_provider": "bedrock_mantle",
|
||||
"max_input_tokens": 272000,
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "responses",
|
||||
|
|
@ -46057,6 +46442,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -46071,6 +46457,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import json
|
|||
import os
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple
|
||||
|
||||
import httpx
|
||||
from pydantic import (
|
||||
|
|
@ -18,7 +18,7 @@ from pydantic import (
|
|||
from typing_extensions import NotRequired, Required, TypedDict
|
||||
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import MCP_STDIO_ALLOWED_COMMANDS
|
||||
from litellm.constants import DEFAULT_STAGGER_WINDOW_SECONDS, MCP_STDIO_ALLOWED_COMMANDS
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
validate_no_callback_env_reference,
|
||||
)
|
||||
|
|
@ -73,6 +73,27 @@ else:
|
|||
Span = Any
|
||||
|
||||
|
||||
class ReconcileOutcome(NamedTuple):
|
||||
"""What a model reconcile observed, captured while it still held the reconcile
|
||||
lock.
|
||||
|
||||
Both fields have to be read under that lock to be worth anything. ``live_after``
|
||||
in particular is the router's serving state the instant this reconcile finished,
|
||||
which is NOT the same as what a later snapshot would see: any other model write
|
||||
admitted in between briefly un-serves every db model (see ``clear_cache``), so a
|
||||
caller that re-snapshots at verdict time can observe that hole and blame its own
|
||||
reload for it.
|
||||
|
||||
- ``still_desired``: the db + config ids the reconcile reconciled against, or None
|
||||
when no reconcile ran and the desired set is therefore unknown.
|
||||
- ``live_after``: the ids the router served immediately after the reconcile, or
|
||||
None when no reconcile ran.
|
||||
"""
|
||||
|
||||
still_desired: frozenset[str] | None
|
||||
live_after: frozenset[str] | None
|
||||
|
||||
|
||||
class SupportedDBObjectType(str, enum.Enum):
|
||||
"""
|
||||
Supported database object types for fine-grained DB storage control.
|
||||
|
|
@ -2251,6 +2272,39 @@ class CoordinationRedisParams(LiteLLMPydanticObjectBase):
|
|||
return any(value is not None for value in (self.host, self.url, self.startup_nodes, self.sentinel_nodes))
|
||||
|
||||
|
||||
class ScheduledJobStaggerSettings(LiteLLMPydanticObjectBase):
|
||||
"""
|
||||
Spreads the proxy's scheduled background jobs across a window instead of firing them
|
||||
all on one instant, on every replica, forever.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid", protected_namespaces=())
|
||||
|
||||
enabled: bool = Field(default=True, description="apply deterministic phase offsets to scheduled background jobs")
|
||||
window_seconds: int = Field(
|
||||
default=DEFAULT_STAGGER_WINDOW_SECONDS,
|
||||
ge=0,
|
||||
description=(
|
||||
"width of the window jobs are spread over. An interval job is never offset by "
|
||||
"more than one of its own periods, so it is not delayed past the wait it already has"
|
||||
),
|
||||
)
|
||||
identity: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"replaces the POD_NAME/HOSTNAME-derived component of the offset hash. Set this "
|
||||
"when replicas share a hostname and would otherwise land on the same offset"
|
||||
),
|
||||
)
|
||||
offsets: Mapping[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"explicit offset in seconds per scheduler job id, overriding the derived value. "
|
||||
"0 pins a job to its unshifted schedule"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
||||
"""
|
||||
Documents all the fields supported by `general_settings` in config.yaml
|
||||
|
|
@ -2437,6 +2491,14 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
None,
|
||||
description="By default, the user calling /team/new is automatically added to the new team as a team admin. If True, proxy admins are no longer auto-added; members explicitly listed in members_with_roles are unaffected. Default is False.",
|
||||
)
|
||||
scheduled_job_stagger: ScheduledJobStaggerSettings | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Spreads the proxy's scheduled background jobs (spend flushes, budget resets, "
|
||||
"config reloads, exports) across a window instead of firing them together on "
|
||||
"every replica. On by default; set to tune the window, pin a job, or turn it off."
|
||||
),
|
||||
)
|
||||
maximum_spend_logs_retention_period: str | None = Field(
|
||||
None,
|
||||
description="Maximum retention period for spend logs (e.g., '7d' for 7 days). Logs older than this will be deleted.",
|
||||
|
|
|
|||
|
|
@ -52,20 +52,20 @@ def _get_models_from_access_groups(
|
|||
model_access_groups: dict[str, list[str]],
|
||||
all_models: list[str],
|
||||
include_model_access_groups: bool | None = False,
|
||||
proxy_model_list: Sequence[str] | None = None,
|
||||
) -> list[str]:
|
||||
idx_to_remove: Final = []
|
||||
new_models: Final = []
|
||||
for idx, model in enumerate(all_models):
|
||||
if model in model_access_groups:
|
||||
if not include_model_access_groups: # remove access group, unless requested - e.g. when creating a key
|
||||
idx_to_remove.append(idx)
|
||||
new_models.extend(model_access_groups[model])
|
||||
|
||||
for idx in sorted(idx_to_remove, reverse=True):
|
||||
all_models.pop(idx)
|
||||
|
||||
all_models.extend(new_models)
|
||||
return all_models
|
||||
# a grant naming both a deployed model and an access group means both at runtime
|
||||
# (_check_model_access_helper unions them), so listings must keep the literal too
|
||||
deployed_model_names: Final = frozenset(proxy_model_list or ())
|
||||
kept_models: Final = [
|
||||
model
|
||||
for model in all_models
|
||||
if model not in model_access_groups or include_model_access_groups or model in deployed_model_names
|
||||
]
|
||||
member_models: Final = [
|
||||
member for model in all_models if model in model_access_groups for member in model_access_groups[model]
|
||||
]
|
||||
return kept_models + member_models
|
||||
|
||||
|
||||
async def get_mcp_server_ids(
|
||||
|
|
@ -128,6 +128,7 @@ def get_key_models(
|
|||
model_access_groups=model_access_groups,
|
||||
all_models=all_models,
|
||||
include_model_access_groups=include_model_access_groups,
|
||||
proxy_model_list=proxy_model_list,
|
||||
)
|
||||
|
||||
# deduplicate while preserving order
|
||||
|
|
@ -169,6 +170,7 @@ def get_team_models(
|
|||
model_access_groups=model_access_groups,
|
||||
all_models=list(all_models_set),
|
||||
include_model_access_groups=include_model_access_groups,
|
||||
proxy_model_list=proxy_model_list,
|
||||
)
|
||||
|
||||
# deduplicate while preserving order
|
||||
|
|
|
|||
|
|
@ -1060,6 +1060,31 @@ async def _read_request_body_deferring_parse_failure(
|
|||
return populate_request_with_path_params(request_data=parsed_body, request=request), None
|
||||
|
||||
|
||||
async def _record_unparsable_body_failure(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
body_parse_exception: ProxyException,
|
||||
route: str,
|
||||
) -> None:
|
||||
"""Record the 400 an unparsable body earns as a failed request log.
|
||||
|
||||
The endpoint never runs for these, so no downstream failure hook writes the
|
||||
spend log row the Admin UI reads. Logging must not change what the caller
|
||||
sees, so a failure here is swallowed and the 400 is raised either way.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
|
||||
try:
|
||||
await proxy_logging_obj.post_call_failure_hook( # pyright: ignore[reportUnknownMemberType] # bare dict in sig
|
||||
request_data={}, # mutable-ok: the failure hook seeds the call id and metadata onto this dict
|
||||
original_exception=body_parse_exception,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
error_type=ProxyErrorTypes.bad_request_error,
|
||||
route=route,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # any logging failure must leave the caller's 400 untouched
|
||||
verbose_proxy_logger.exception("Failed to log the request rejected for an unparsable body: %s", e)
|
||||
|
||||
|
||||
async def _user_api_key_auth_builder(
|
||||
request: Request,
|
||||
api_key: str,
|
||||
|
|
@ -2673,6 +2698,11 @@ async def user_api_key_auth(
|
|||
user_api_key_auth_obj.request_route = normalize_request_route(route)
|
||||
|
||||
if body_parse_exception is not None:
|
||||
await _record_unparsable_body_failure(
|
||||
user_api_key_dict=user_api_key_auth_obj,
|
||||
body_parse_exception=body_parse_exception,
|
||||
route=route,
|
||||
)
|
||||
raise body_parse_exception
|
||||
|
||||
# Resolve caller identity once, here at the seam, into a single per-request
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ from typing import TYPE_CHECKING, Any, Final, Optional
|
|||
import litellm
|
||||
from litellm import get_secret
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import PRE_CALL_EXECUTED_GUARDRAILS_KEY, SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY
|
||||
from litellm.constants import (
|
||||
CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
|
||||
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.core_helpers import (
|
||||
get_metadata_variable_name_from_kwargs,
|
||||
|
|
@ -426,6 +430,7 @@ LITELLM_PROXY_INTERNAL_METADATA_KEYS: Final = frozenset(
|
|||
"_pipeline_managed_guardrails",
|
||||
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
|
||||
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
|
||||
CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
"disable_global_guardrails",
|
||||
"disable_global_guardrail",
|
||||
"opted_out_global_guardrails",
|
||||
|
|
|
|||
347
litellm/proxy/common_utils/scheduled_job_stagger.py
Normal file
347
litellm/proxy/common_utils/scheduled_job_stagger.py
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
"""
|
||||
Deterministic phase offsets for the proxy's scheduled background jobs.
|
||||
|
||||
APScheduler anchors an ``interval`` job at ``now + interval``, so every job registered in
|
||||
the same startup shares one firing instant for the life of the process, and every replica
|
||||
brought up by the same rollout shares it too. The result is a burst: each tick, every job
|
||||
on every replica queries Postgres at the same moment, competing with the request path for
|
||||
the connection pool. The product's own daily/monthly crons are worse still, since they name
|
||||
a wall-clock instant that is identical on every replica by construction.
|
||||
|
||||
The fix is a phase offset derived from ``sha256(job_id, identity)``, where ``identity``
|
||||
covers the pod and the worker process. Different jobs get different offsets, different
|
||||
replicas get different offsets for the same job, and nothing collapses back onto a shared
|
||||
instant after a restart. Hashing rather than randomising keeps a given process's schedule
|
||||
stable for its whole life and lets the applied offsets be logged once and reasoned about
|
||||
later.
|
||||
|
||||
The offset lives in the trigger rather than in a one-off ``next_run_time`` because a cron
|
||||
trigger recomputes each fire from the wall clock and would otherwise snap straight back
|
||||
onto the shared instant after its first shifted run.
|
||||
|
||||
Only schedules LiteLLM itself chose are shifted. Interval jobs are always eligible; cron
|
||||
jobs only when their id is one of the product's own defaults, so an operator-supplied
|
||||
crontab keeps the exact instant it asks for. A job whose call site passed an explicit
|
||||
``next_run_time`` already anchors itself and is left alone.
|
||||
"""
|
||||
|
||||
# apscheduler ships no type information, so its imports have no stubs. The Protocols below
|
||||
# narrow everything it hands back, which is why this is the only diagnostic left to silence.
|
||||
# pyright: reportMissingTypeStubs=false
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import socket
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol
|
||||
|
||||
from apscheduler.events import EVENT_JOB_SUBMITTED
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import (
|
||||
MONTHLY_SPEND_REPORT_JOB_ID,
|
||||
PROMETHEUS_FALLBACK_STATS_JOB_ID,
|
||||
PTU_ROLLUP_JOB_ID,
|
||||
PTU_ROLLUP_LOCK_TTL_SECONDS,
|
||||
)
|
||||
from litellm.proxy._types import ScheduledJobStaggerSettings
|
||||
|
||||
GENERAL_SETTINGS_KEY: Final = "scheduled_job_stagger"
|
||||
|
||||
#: Cron schedules LiteLLM picks on the operator's behalf, so shifting them changes nothing the
|
||||
#: operator asked for. Every other cron trigger is an operator-supplied crontab, preserved exactly.
|
||||
#:
|
||||
#: The value is the span over which a second firing would redo work the first already did, which
|
||||
#: is how long each job's leader-election lock stays held. Two replicas further apart than that
|
||||
#: both find the key free and both run, which for the spend report means the customer gets it
|
||||
#: twice. Offsets for these jobs are bounded by it, so widening the window cannot resurrect the
|
||||
#: duplicate-work failure this feature exists to avoid.
|
||||
DEFAULT_CRON_DEDUPE_SECONDS: Final = MappingProxyType(
|
||||
{
|
||||
MONTHLY_SPEND_REPORT_JOB_ID: 3600,
|
||||
PROMETHEUS_FALLBACK_STATS_JOB_ID: 3600,
|
||||
PTU_ROLLUP_JOB_ID: PTU_ROLLUP_LOCK_TTL_SECONDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class Trigger(Protocol):
|
||||
"""The one method APScheduler asks a trigger for"""
|
||||
|
||||
def get_next_fire_time(self, previous_fire_time: datetime | None, now: datetime) -> datetime | None: ...
|
||||
|
||||
|
||||
class ScheduledJob(Protocol):
|
||||
@property
|
||||
def id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def trigger(self) -> Trigger: ...
|
||||
|
||||
|
||||
class JobScheduler(Protocol):
|
||||
"""The slice of ``AsyncIOScheduler`` this module uses, which ships no type information"""
|
||||
|
||||
@property
|
||||
def running(self) -> bool: ...
|
||||
|
||||
def get_jobs(self) -> Sequence[ScheduledJob]: ...
|
||||
|
||||
def modify_job(self, job_id: str, *, trigger: Trigger) -> object: ...
|
||||
|
||||
def add_listener(self, callback: Callable[["JobSubmission"], None], mask: int = ...) -> None: ...
|
||||
|
||||
|
||||
class JobSubmission(Protocol):
|
||||
"""An ``EVENT_JOB_SUBMITTED`` event"""
|
||||
|
||||
@property
|
||||
def job_id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def scheduled_run_times(self) -> Sequence[datetime]: ...
|
||||
|
||||
|
||||
class _OffsetTrigger:
|
||||
"""
|
||||
Delegates to ``base`` on a clock rolled back by ``offset``, then rolls the answer
|
||||
forward again, so every fire lands exactly ``offset`` later than it otherwise would
|
||||
while the underlying schedule keeps its own semantics.
|
||||
|
||||
Composed rather than derived from ``BaseTrigger``: APScheduler only ever asks a trigger
|
||||
for its next fire time, and it accepts this by virtual registration below.
|
||||
"""
|
||||
|
||||
__slots__ = ("base", "offset")
|
||||
|
||||
def __init__(self, base: Trigger, offset: timedelta) -> None:
|
||||
self.base = base
|
||||
self.offset = offset
|
||||
|
||||
def get_next_fire_time(self, previous_fire_time: datetime | None, now: datetime) -> datetime | None:
|
||||
shifted_previous: Final = None if previous_fire_time is None else previous_fire_time - self.offset
|
||||
next_fire_time: Final = self.base.get_next_fire_time(shifted_previous, now - self.offset)
|
||||
return None if next_fire_time is None else next_fire_time + self.offset
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.base}[+{int(self.offset.total_seconds())}s]"
|
||||
|
||||
|
||||
# APScheduler type-checks assigned triggers with isinstance, so it has to accept this one
|
||||
BaseTrigger.register(_OffsetTrigger)
|
||||
|
||||
|
||||
def parse_stagger_settings(general_settings: Mapping[str, object]) -> ScheduledJobStaggerSettings:
|
||||
raw: Final = general_settings.get(GENERAL_SETTINGS_KEY)
|
||||
if raw is None:
|
||||
return ScheduledJobStaggerSettings()
|
||||
try:
|
||||
return ScheduledJobStaggerSettings.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
verbose_proxy_logger.warning(
|
||||
"Ignoring invalid general_settings.%s, falling back to defaults: %s",
|
||||
GENERAL_SETTINGS_KEY,
|
||||
exc,
|
||||
)
|
||||
return ScheduledJobStaggerSettings()
|
||||
|
||||
|
||||
def resolve_stagger_identity(configured: str | None) -> str:
|
||||
"""
|
||||
The value hashed alongside a job id to place this process in the stagger window.
|
||||
|
||||
The process id is part of it because a pod runs one scheduler per uvicorn worker, and
|
||||
workers sharing a hostname would otherwise all land on the same offset. That makes the
|
||||
offsets change across restarts, which is what stops a simultaneous rollout from
|
||||
reconverging; the applied values are logged so a given run stays explainable.
|
||||
"""
|
||||
host: Final = configured or os.getenv("POD_NAME") or os.getenv("HOSTNAME") or _hostname()
|
||||
return f"{host}:{os.getpid()}"
|
||||
|
||||
|
||||
def _hostname() -> str:
|
||||
try:
|
||||
return socket.gethostname()
|
||||
except OSError:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def offset_seconds(*, job_id: str, identity: str, window_seconds: int) -> int:
|
||||
"""A stable point in ``[0, window_seconds)`` for this job on this process"""
|
||||
if window_seconds <= 0:
|
||||
return 0
|
||||
digest: Final = hashlib.sha256(f"{job_id}\x00{identity}".encode()).digest()
|
||||
return int.from_bytes(digest[:8], "big") % window_seconds
|
||||
|
||||
|
||||
def _interval_seconds(job: ScheduledJob) -> int | None:
|
||||
if not isinstance(job.trigger, IntervalTrigger):
|
||||
return None
|
||||
interval: Final = getattr(job.trigger, "interval", None)
|
||||
return int(interval.total_seconds()) if isinstance(interval, timedelta) else None
|
||||
|
||||
|
||||
def _is_staggerable(job: ScheduledJob) -> bool:
|
||||
if hasattr(job, "next_run_time"):
|
||||
# the call site anchored the first fire itself
|
||||
return False
|
||||
if _interval_seconds(job) is not None:
|
||||
return True
|
||||
return job.id in DEFAULT_CRON_DEDUPE_SECONDS
|
||||
|
||||
|
||||
def _window_for(*, job_id: str, period_seconds: int | None, settings: ScheduledJobStaggerSettings) -> int:
|
||||
"""
|
||||
Exclusive upper bound on this job's offset. An interval job is never offset by more than
|
||||
one of its own periods, so it is not delayed past the wait it already had, and a
|
||||
leader-elected cron is never offset past the span in which a second replica would redo
|
||||
its work.
|
||||
"""
|
||||
limits: Final = (settings.window_seconds, period_seconds, DEFAULT_CRON_DEDUPE_SECONDS.get(job_id))
|
||||
return min(limit for limit in limits if limit is not None)
|
||||
|
||||
|
||||
def _clamped_override(*, job_id: str, requested: int) -> int:
|
||||
horizon: Final = DEFAULT_CRON_DEDUPE_SECONDS.get(job_id)
|
||||
if horizon is None or requested < horizon:
|
||||
return requested
|
||||
verbose_proxy_logger.warning(
|
||||
"general_settings.%s.offsets[%s]=%ss would place replicas more than %ss apart, "
|
||||
"which is long enough for a second replica to redo the run; using %ss instead",
|
||||
GENERAL_SETTINGS_KEY,
|
||||
job_id,
|
||||
requested,
|
||||
horizon,
|
||||
horizon - 1,
|
||||
)
|
||||
return horizon - 1
|
||||
|
||||
|
||||
def _offset_for(
|
||||
*,
|
||||
job_id: str,
|
||||
period_seconds: int | None,
|
||||
staggerable: bool,
|
||||
settings: ScheduledJobStaggerSettings,
|
||||
identity: str,
|
||||
) -> int:
|
||||
override: Final = settings.offsets.get(job_id)
|
||||
if override is not None:
|
||||
return _clamped_override(job_id=job_id, requested=max(0, override))
|
||||
if not staggerable:
|
||||
return 0
|
||||
return offset_seconds(
|
||||
job_id=job_id,
|
||||
identity=identity,
|
||||
window_seconds=_window_for(job_id=job_id, period_seconds=period_seconds, settings=settings),
|
||||
)
|
||||
|
||||
|
||||
def stagger_trigger(
|
||||
*,
|
||||
job_id: str,
|
||||
trigger: Trigger,
|
||||
period_seconds: int | None,
|
||||
settings: ScheduledJobStaggerSettings,
|
||||
identity: str | None = None,
|
||||
) -> Trigger:
|
||||
"""
|
||||
The trigger a job should carry, shifted by its own share of the window.
|
||||
|
||||
For a job registered against an already-running scheduler, which the startup sweep cannot
|
||||
reach: every job carries a ``next_run_time`` by then, so re-running the sweep would treat
|
||||
them all as self-anchored and change nothing.
|
||||
"""
|
||||
offset: Final = _offset_for(
|
||||
job_id=job_id,
|
||||
period_seconds=period_seconds,
|
||||
staggerable=True,
|
||||
settings=settings,
|
||||
identity=identity or resolve_stagger_identity(settings.identity),
|
||||
)
|
||||
return trigger if offset == 0 else _OffsetTrigger(trigger, timedelta(seconds=offset))
|
||||
|
||||
|
||||
def apply_scheduled_job_stagger(
|
||||
*,
|
||||
scheduler: JobScheduler,
|
||||
settings: ScheduledJobStaggerSettings,
|
||||
identity: str | None = None,
|
||||
) -> Mapping[str, int]:
|
||||
"""
|
||||
Shift each eligible job's schedule by its own offset. Call this once, after every job is
|
||||
registered and before the scheduler starts, so the offset is folded into the first fire
|
||||
rather than applied to a schedule already running.
|
||||
|
||||
``identity`` is resolved from the environment when the caller does not supply one.
|
||||
|
||||
Returns the offset applied to every registered job, including the zeroes, so the caller
|
||||
and the logs describe the same thing.
|
||||
"""
|
||||
resolved_identity: Final = identity or resolve_stagger_identity(settings.identity)
|
||||
if scheduler.running:
|
||||
# every job already carries a next_run_time by now, so the sweep would skip all of
|
||||
# them and report success while changing nothing
|
||||
verbose_proxy_logger.warning(
|
||||
"Scheduled job stagger skipped: the scheduler is already running, so offsets must be "
|
||||
"applied before it starts"
|
||||
)
|
||||
return MappingProxyType({job.id: 0 for job in scheduler.get_jobs()})
|
||||
if not settings.enabled:
|
||||
verbose_proxy_logger.info(
|
||||
"Scheduled job stagger disabled via general_settings.%s; all jobs keep their unshifted schedule",
|
||||
GENERAL_SETTINGS_KEY,
|
||||
)
|
||||
return MappingProxyType({job.id: 0 for job in scheduler.get_jobs()})
|
||||
|
||||
offsets: Final = MappingProxyType(
|
||||
{
|
||||
job.id: _offset_for(
|
||||
job_id=job.id,
|
||||
period_seconds=_interval_seconds(job),
|
||||
staggerable=_is_staggerable(job),
|
||||
settings=settings,
|
||||
identity=resolved_identity,
|
||||
)
|
||||
for job in scheduler.get_jobs()
|
||||
}
|
||||
)
|
||||
for job in scheduler.get_jobs():
|
||||
if offsets[job.id] > 0:
|
||||
scheduler.modify_job(
|
||||
job.id,
|
||||
trigger=_OffsetTrigger(job.trigger, timedelta(seconds=offsets[job.id])),
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
"Scheduled job stagger applied (identity=%s, window=%ss): %s",
|
||||
resolved_identity,
|
||||
settings.window_seconds,
|
||||
", ".join(f"{job_id}=+{seconds}s" for job_id, seconds in sorted(offsets.items())),
|
||||
)
|
||||
return offsets
|
||||
|
||||
|
||||
def attach_job_timing_logger(scheduler: JobScheduler) -> None:
|
||||
"""Log each fire's scheduled instant against the instant it actually started"""
|
||||
scheduler.add_listener(_log_job_submitted, EVENT_JOB_SUBMITTED)
|
||||
|
||||
|
||||
def _log_job_submitted(event: JobSubmission) -> None:
|
||||
if not event.scheduled_run_times:
|
||||
return
|
||||
scheduled: Final = event.scheduled_run_times[0]
|
||||
started: Final = datetime.now(scheduled.tzinfo)
|
||||
verbose_proxy_logger.debug(
|
||||
"Scheduled job %s started: scheduled_run_time=%s actual_start_time=%s delay=%.3fs",
|
||||
event.job_id,
|
||||
scheduled.isoformat(),
|
||||
started.isoformat(),
|
||||
(started - scheduled).total_seconds(),
|
||||
)
|
||||
|
|
@ -21,6 +21,17 @@ def _coerce_interval(ping_interval_seconds: float | str | None) -> float | None:
|
|||
return interval
|
||||
|
||||
|
||||
def keepalive_ping_has_fired(elapsed_seconds: float, ping_interval_seconds: float | str | None) -> bool:
|
||||
"""Whether a keepalive ping has already gone out, which flushes the response headers.
|
||||
|
||||
A caller that discovers a failure after that point cannot raise its way to the client, since
|
||||
the status line is already on the wire. With pings disabled nothing flushes early, so a raise
|
||||
still carries its real status.
|
||||
"""
|
||||
interval: Final = _coerce_interval(ping_interval_seconds)
|
||||
return interval is not None and elapsed_seconds >= interval
|
||||
|
||||
|
||||
def wrap_sse_stream_with_keepalive_pings(
|
||||
stream: AsyncGenerator[str, None],
|
||||
ping_interval_seconds: float | str | None,
|
||||
|
|
|
|||
125
litellm/proxy/guardrails/anthropic_sse.py
Normal file
125
litellm/proxy/guardrails/anthropic_sse.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Anthropic SSE <-> ModelResponse conversion for guardrail streaming hooks.
|
||||
|
||||
`/v1/messages` streams reach a guardrail's `async_post_call_streaming_iterator_hook` as raw SSE
|
||||
frames rather than chunk objects, which `stream_chunk_builder` cannot assemble. These helpers let a
|
||||
hook scan such a stream, and re-emit it when the guardrail rewrote the response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final
|
||||
|
||||
from litellm.types.utils import Choices, ModelResponse
|
||||
|
||||
|
||||
def is_raw_sse_stream(all_chunks: Sequence[object]) -> bool:
|
||||
return any(isinstance(chunk, (str, bytes)) for chunk in all_chunks)
|
||||
|
||||
|
||||
def _joined_sse_stream(all_chunks: Sequence[object]) -> str | None:
|
||||
raw: Final = b"".join(
|
||||
chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")
|
||||
for chunk in all_chunks
|
||||
if isinstance(chunk, (str, bytes))
|
||||
)
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _anthropic_message_start(sse_stream: str) -> Mapping[str, object] | None:
|
||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
|
||||
AnthropicPassthroughLoggingHandler,
|
||||
)
|
||||
|
||||
return next(
|
||||
(
|
||||
message
|
||||
for event in AnthropicPassthroughLoggingHandler._split_sse_chunk_into_events(sse_stream) # pyright: ignore[reportPrivateUsage] # same parser the assembler uses
|
||||
if (event_data := AnthropicPassthroughLoggingHandler._extract_sse_data(event)) is not None # pyright: ignore[reportPrivateUsage] # same parser the assembler uses; a private import beats forking SSE parsing
|
||||
and event_data.get("type") == "message_start"
|
||||
and isinstance(message := event_data.get("message"), dict)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def assemble_anthropic_sse_stream(
|
||||
all_chunks: Sequence[object], *, restore_identity: bool = False
|
||||
) -> ModelResponse | None:
|
||||
"""Assemble raw Anthropic SSE frames into a ModelResponse.
|
||||
|
||||
``restore_identity`` stamps the upstream message id and model onto the result, which the
|
||||
assembler does not carry through. It is off by default so callers that re-emit the assembled
|
||||
response keep the wire shape they had before this helper was shared. The writes land on a
|
||||
freshly built object that is unreachable from caller state until returned.
|
||||
"""
|
||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
|
||||
AnthropicPassthroughLoggingHandler,
|
||||
)
|
||||
|
||||
sse_stream: Final = _joined_sse_stream(all_chunks)
|
||||
if sse_stream is None:
|
||||
return None
|
||||
message_start: Final = _anthropic_message_start(sse_stream)
|
||||
if message_start is None:
|
||||
return None
|
||||
model: Final = message_start.get("model") if restore_identity else None
|
||||
try:
|
||||
assembled: Final = AnthropicPassthroughLoggingHandler._build_complete_streaming_response( # pyright: ignore[reportPrivateUsage] # the only SSE-to-ModelResponse assembler; reimplementing it here would fork the parser
|
||||
all_chunks=(sse_stream,),
|
||||
litellm_logging_obj=None, # pyright: ignore[reportArgumentType] # only forwarded to stream_chunk_builder, which accepts None
|
||||
model=model if isinstance(model, str) else "",
|
||||
)
|
||||
except Exception: # noqa: BLE001 # stream_chunk_builder re-raises every assembly failure as litellm.APIError
|
||||
return None
|
||||
if not isinstance(assembled, ModelResponse):
|
||||
return None
|
||||
if not restore_identity:
|
||||
return assembled
|
||||
message_id: Final = message_start.get("id")
|
||||
if isinstance(message_id, str):
|
||||
assembled.id = message_id
|
||||
if isinstance(model, str) and model:
|
||||
assembled.model = model
|
||||
return assembled
|
||||
|
||||
|
||||
def model_response_text(response: ModelResponse) -> str:
|
||||
"""Assistant text of a response, used to detect whether a guardrail rewrote it."""
|
||||
return "".join(
|
||||
choice.message.content
|
||||
for choice in response.choices
|
||||
if isinstance(choice, Choices) # pyright: ignore[reportUnnecessaryIsInstance] # runtime choices can be StreamingChoices
|
||||
and isinstance(choice.message.content, str)
|
||||
)
|
||||
|
||||
|
||||
def anthropic_sse_error_frames(message: str) -> tuple[bytes, ...]:
|
||||
"""Anthropic error event, for a failure discovered after the response headers were flushed.
|
||||
|
||||
Once a keepalive ping has been sent a raise cannot reach the client, so the failure has to
|
||||
travel as a frame.
|
||||
"""
|
||||
body: Final = json.dumps(message)
|
||||
return (
|
||||
f'event: error\ndata: {{"type": "error", "error": {{"type": "guardrail_error", '
|
||||
f'"message": {body}}}}}\n\n'.encode(),
|
||||
)
|
||||
|
||||
|
||||
def anthropic_sse_chunks_from_response(assembled: ModelResponse) -> tuple[bytes, ...]:
|
||||
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
|
||||
LiteLLMAnthropicMessagesAdapter,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.fake_stream_iterator import (
|
||||
FakeAnthropicMessagesStreamIterator,
|
||||
)
|
||||
|
||||
anthropic_response: Final = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
|
||||
response=assembled
|
||||
)
|
||||
return tuple(FakeAnthropicMessagesStreamIterator(response=anthropic_response).chunks)
|
||||
|
|
@ -14,6 +14,7 @@ import copy
|
|||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import AsyncGenerator, Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from itertools import accumulate, groupby
|
||||
|
|
@ -30,6 +31,7 @@ from litellm.constants import BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS
|
|||
from litellm.exceptions import ModifyResponseException
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_regex_keys
|
||||
from litellm.llms.anthropic.chat.guardrail_translation.handler import AnthropicMessagesHandler
|
||||
from litellm.llms.base_llm.guardrail_translation.utils import (
|
||||
effective_scan_only_tool_results_for_guardrail,
|
||||
)
|
||||
|
|
@ -39,6 +41,15 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_request_processing import _serialize_http_exception_detail
|
||||
from litellm.proxy.common_utils.sse_keepalive import keepalive_ping_has_fired
|
||||
from litellm.proxy.guardrails.anthropic_sse import (
|
||||
anthropic_sse_chunks_from_response,
|
||||
anthropic_sse_error_frames,
|
||||
assemble_anthropic_sse_stream,
|
||||
is_raw_sse_stream,
|
||||
model_response_text,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.guardrails import BedrockChecksConfigModel, GuardrailEventHooks
|
||||
from litellm.types.llms.openai import AllMessageValues, ChatCompletionUserMessage
|
||||
|
|
@ -2578,14 +2589,21 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
from litellm.types.utils import TextCompletionResponse
|
||||
|
||||
# Collect all chunks to process them together
|
||||
started_at: Final = time.monotonic()
|
||||
all_chunks: Final[list[ModelResponseStream]] = []
|
||||
async for chunk in response:
|
||||
all_chunks.append(chunk)
|
||||
|
||||
assembled_model_response: ModelResponse | TextCompletionResponse | None = stream_chunk_builder(
|
||||
chunks=all_chunks,
|
||||
# /v1/messages arrives as SSE frames, which stream_chunk_builder cannot assemble
|
||||
raw_sse: Final = is_raw_sse_stream(all_chunks)
|
||||
assembled_model_response: ModelResponse | TextCompletionResponse | None = (
|
||||
assemble_anthropic_sse_stream(all_chunks, restore_identity=True)
|
||||
if raw_sse
|
||||
else stream_chunk_builder(chunks=all_chunks)
|
||||
)
|
||||
if isinstance(assembled_model_response, ModelResponse):
|
||||
pre_guardrail_text: Final = model_response_text(assembled_model_response)
|
||||
_pre_block_response: Final = assembled_model_response
|
||||
####################################################################
|
||||
########## 1. Make Bedrock Apply Guardrail API request ##########
|
||||
#
|
||||
|
|
@ -2609,7 +2627,32 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
request_data=request_data,
|
||||
logging_event_type=GuardrailEventHooks.post_call,
|
||||
)
|
||||
except HTTPException as block_exc:
|
||||
block_detail: Final = block_exc.detail
|
||||
# A policy block is the only 400 carrying a structured detail; a service failure
|
||||
# either details a plain string or reports a non-400 status. Re-raising a service
|
||||
# failure keeps its real status, but only while the headers are unflushed: past the
|
||||
# first keepalive ping the raise reaches nobody, so it has to travel as a frame too
|
||||
is_block: Final = raw_sse and block_exc.status_code == 400 and isinstance(block_detail, Mapping)
|
||||
headers_flushed: Final = keepalive_ping_has_fired(
|
||||
time.monotonic() - started_at, litellm.anthropic_sse_ping_interval_seconds
|
||||
)
|
||||
if not raw_sse or (not is_block and not headers_flushed):
|
||||
raise
|
||||
block_message, _ = _serialize_http_exception_detail(block_detail)
|
||||
for error_frame in anthropic_sse_error_frames(
|
||||
block_message if is_block else f"{block_exc.status_code}: {block_message}"
|
||||
):
|
||||
yield error_frame
|
||||
return
|
||||
except ModifyResponseException as e:
|
||||
if raw_sse:
|
||||
e.model = _pre_block_response.model or e.model # rebind-ok: exc.model defaults to the guardrail
|
||||
if e.original_response is None:
|
||||
e.original_response = _pre_block_response # rebind-ok: the block builder reads usage off this
|
||||
for block_chunk in AnthropicMessagesHandler().build_block_sse_chunks(e, stream_started=False):
|
||||
yield block_chunk
|
||||
return
|
||||
# Preserve upstream usage from the LLM call we already
|
||||
# consumed. Non-streaming blocks carry it via
|
||||
# ModifyResponseException.original_response +
|
||||
|
|
@ -2642,11 +2685,29 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
#########################################################################
|
||||
########## 3. Return the (potentially masked) chunks ##########
|
||||
#########################################################################
|
||||
if raw_sse:
|
||||
for sse_chunk in (
|
||||
anthropic_sse_chunks_from_response(assembled_model_response)
|
||||
if model_response_text(assembled_model_response) != pre_guardrail_text
|
||||
else all_chunks
|
||||
):
|
||||
yield sse_chunk
|
||||
return
|
||||
|
||||
mock_response: Final = MockResponseIterator(model_response=assembled_model_response)
|
||||
|
||||
# Return the reconstructed stream
|
||||
async for chunk in mock_response:
|
||||
yield chunk
|
||||
elif raw_sse:
|
||||
# Forwarding an unscannable stream would silently disable the guardrail, so fail closed.
|
||||
# A raise cannot reach the client once a keepalive ping has flushed the headers, so the
|
||||
# refusal travels as a frame, matching how a block is delivered above
|
||||
for error_frame in anthropic_sse_error_frames(
|
||||
f"{self.guardrail_name}: streamed response could not be assembled for scanning, blocking it"
|
||||
):
|
||||
yield error_frame
|
||||
return
|
||||
else:
|
||||
for chunk in all_chunks:
|
||||
yield chunk
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ from litellm.proxy._types import UserAPIKeyAuth
|
|||
from litellm.proxy.common_utils.callback_utils import (
|
||||
add_guardrail_to_applied_guardrails_header,
|
||||
)
|
||||
from litellm.proxy.guardrails.anthropic_sse import (
|
||||
anthropic_sse_chunks_from_response,
|
||||
assemble_anthropic_sse_stream,
|
||||
is_raw_sse_stream,
|
||||
)
|
||||
from litellm.types.guardrails import GuardrailEventHooks, LitellmParams
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.tool_permission import (
|
||||
PermissionError,
|
||||
|
|
@ -870,7 +875,7 @@ class ToolPermissionGuardrail(CustomGuardrail):
|
|||
all_chunks.append(chunk)
|
||||
|
||||
assembled_model_response: Final[ModelResponse | TextCompletionResponse | None] = (
|
||||
stream_chunk_builder(chunks=all_chunks) if not self._is_raw_sse_stream(all_chunks) else None
|
||||
stream_chunk_builder(chunks=all_chunks) if not is_raw_sse_stream(all_chunks) else None
|
||||
)
|
||||
if isinstance(assembled_model_response, ModelResponse):
|
||||
denied_tools = self._check_assembled_stream(assembled_model_response)
|
||||
|
|
@ -883,9 +888,9 @@ class ToolPermissionGuardrail(CustomGuardrail):
|
|||
yield chunk
|
||||
return
|
||||
|
||||
anthropic_response: Final = self._assemble_anthropic_stream(all_chunks)
|
||||
anthropic_response: Final = assemble_anthropic_sse_stream(all_chunks)
|
||||
if anthropic_response is None:
|
||||
if self._is_raw_sse_stream(all_chunks):
|
||||
if is_raw_sse_stream(all_chunks):
|
||||
raise GuardrailRaisedException(
|
||||
guardrail_name=self.guardrail_name,
|
||||
message=(
|
||||
|
|
@ -904,13 +909,9 @@ class ToolPermissionGuardrail(CustomGuardrail):
|
|||
return
|
||||
|
||||
self._modify_response_with_permission_errors(anthropic_response, anthropic_denials)
|
||||
for sse_chunk in self._rewritten_anthropic_sse_chunks(anthropic_response):
|
||||
for sse_chunk in anthropic_sse_chunks_from_response(anthropic_response):
|
||||
yield sse_chunk
|
||||
|
||||
@staticmethod
|
||||
def _is_raw_sse_stream(all_chunks: Sequence[Any]) -> bool:
|
||||
return any(isinstance(chunk, (str, bytes)) for chunk in all_chunks)
|
||||
|
||||
def _check_assembled_stream(
|
||||
self, assembled: ModelResponse
|
||||
) -> tuple[tuple[ChatCompletionMessageToolCall, PermissionError], ...]:
|
||||
|
|
@ -924,60 +925,3 @@ class ToolPermissionGuardrail(CustomGuardrail):
|
|||
if not denied_tools:
|
||||
verbose_proxy_logger.debug("Tool Permission Guardrail Post-Call Hook: All tools allowed")
|
||||
return denied_tools
|
||||
|
||||
@staticmethod
|
||||
def _joined_sse_stream(all_chunks: Sequence[Any]) -> str | None:
|
||||
raw: Final = b"".join(
|
||||
chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")
|
||||
for chunk in all_chunks
|
||||
if isinstance(chunk, (str, bytes))
|
||||
)
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _has_anthropic_message_start(sse_stream: str) -> bool:
|
||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
|
||||
AnthropicPassthroughLoggingHandler,
|
||||
)
|
||||
|
||||
return any(
|
||||
(event_data := AnthropicPassthroughLoggingHandler._extract_sse_data(event)) is not None # pyright: ignore[reportPrivateUsage] # same parser the assembler uses; a private import beats forking SSE parsing
|
||||
and event_data.get("type") == "message_start"
|
||||
for event in AnthropicPassthroughLoggingHandler._split_sse_chunk_into_events(sse_stream) # pyright: ignore[reportPrivateUsage] # same parser the assembler uses
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _assemble_anthropic_stream(all_chunks: Sequence[Any]) -> ModelResponse | None:
|
||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
|
||||
AnthropicPassthroughLoggingHandler,
|
||||
)
|
||||
|
||||
sse_stream: Final = ToolPermissionGuardrail._joined_sse_stream(all_chunks)
|
||||
if sse_stream is None or not ToolPermissionGuardrail._has_anthropic_message_start(sse_stream):
|
||||
return None
|
||||
try:
|
||||
assembled = AnthropicPassthroughLoggingHandler._build_complete_streaming_response( # pyright: ignore[reportPrivateUsage] # the only SSE-to-ModelResponse assembler; reimplementing it here would fork the parser
|
||||
all_chunks=(sse_stream,),
|
||||
litellm_logging_obj=None, # pyright: ignore[reportArgumentType] # only forwarded to stream_chunk_builder, which accepts None
|
||||
model="",
|
||||
)
|
||||
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
return assembled if isinstance(assembled, ModelResponse) else None
|
||||
|
||||
@staticmethod
|
||||
def _rewritten_anthropic_sse_chunks(assembled: ModelResponse) -> tuple[bytes, ...]:
|
||||
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
|
||||
LiteLLMAnthropicMessagesAdapter,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.fake_stream_iterator import (
|
||||
FakeAnthropicMessagesStreamIterator,
|
||||
)
|
||||
|
||||
anthropic_response: Final = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
|
||||
response=assembled
|
||||
)
|
||||
return tuple(FakeAnthropicMessagesStreamIterator(response=anthropic_response).chunks)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from litellm.router_utils.clientside_credential_handler import (
|
|||
_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the router path
|
||||
clientside_credential_keys,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_bool
|
||||
|
||||
#### Health ENDPOINTS ####
|
||||
|
||||
|
|
@ -1447,6 +1448,31 @@ def callback_name(callback):
|
|||
return str(callback)
|
||||
|
||||
|
||||
DISABLE_NO_REDIS_WARNING_ENV_VAR: Final = "LITELLM_DISABLE_NO_REDIS_WARNING"
|
||||
|
||||
|
||||
def _show_no_redis_warning() -> bool:
|
||||
"""
|
||||
Whether the UI should warn that no Redis is configured.
|
||||
|
||||
Redis is what makes rate limits, budgets, router state, and cache
|
||||
invalidation consistent across workers, so a proxy running without it is
|
||||
only safe as a single worker. Both places a Redis can land count: the
|
||||
coordination cache (from a Redis response cache, general_settings.
|
||||
coordination_redis, or the REDIS_* env fallback) and the router's own
|
||||
Redis (router_settings.redis_host), which backs cooldowns and usage-based
|
||||
routing on its own. Operators who know they run one worker can silence the
|
||||
warning with LITELLM_DISABLE_NO_REDIS_WARNING=true.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import llm_router, redis_usage_cache
|
||||
|
||||
if redis_usage_cache is not None:
|
||||
return False
|
||||
if llm_router is not None and llm_router.cache.redis_cache is not None:
|
||||
return False
|
||||
return get_secret_bool(DISABLE_NO_REDIS_WARNING_ENV_VAR, False) is not True
|
||||
|
||||
|
||||
async def _get_health_readiness_details(
|
||||
response: Response | None = None,
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -1487,6 +1513,7 @@ async def _get_health_readiness_details(
|
|||
# check log level
|
||||
log_level_name: Final = logging.getLevelName(verbose_logger.getEffectiveLevel())
|
||||
is_detailed_debug: Final = verbose_logger.isEnabledFor(logging.DEBUG)
|
||||
show_no_redis_warning: Final = _show_no_redis_warning()
|
||||
|
||||
# check DB
|
||||
if prisma_client is not None: # if db passed in, check if it's connected
|
||||
|
|
@ -1506,6 +1533,7 @@ async def _get_health_readiness_details(
|
|||
"use_aiohttp_transport": AsyncHTTPHandler._should_use_aiohttp_transport(),
|
||||
"log_level": log_level_name,
|
||||
"is_detailed_debug": is_detailed_debug,
|
||||
"show_no_redis_warning": show_no_redis_warning,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
|
|
@ -1517,6 +1545,7 @@ async def _get_health_readiness_details(
|
|||
"use_aiohttp_transport": AsyncHTTPHandler._should_use_aiohttp_transport(),
|
||||
"log_level": log_level_name,
|
||||
"is_detailed_debug": is_detailed_debug,
|
||||
"show_no_redis_warning": show_no_redis_warning,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Service Unhealthy ({e})")
|
||||
|
|
|
|||
|
|
@ -39,11 +39,10 @@ _UNATTRIBUTED_TRACKABLE_CALL_TYPES: Final[frozenset[str]] = frozenset(
|
|||
CallTypes.pass_through.value,
|
||||
CallTypes.llm_passthrough_route.value,
|
||||
CallTypes.allm_passthrough_route.value,
|
||||
# CheckBatchCost's synthetic logging_obj for a completed managed batch only ever
|
||||
# carries user_api_key_user_id (from LiteLLM_ManagedObjectTable.created_by) and
|
||||
# user_api_key_team_id (from .team_id) -- both are None for batches created with
|
||||
# the master key or a team-less key, since the table never stores the raw key
|
||||
# hash. The batch already incurred real provider cost, so track it regardless.
|
||||
# CheckBatchCost's synthetic logging_obj for a completed managed batch carries
|
||||
# whatever LiteLLM_ManagedObjectTable stored at create time, and all of it is
|
||||
# None for a batch created before those columns were persisted, or by the master
|
||||
# key. The batch already incurred real provider cost, so track it regardless.
|
||||
CallTypes.aretrieve_batch.value,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import litellm
|
|||
from litellm._logging import verbose_logger, verbose_proxy_logger
|
||||
from litellm._service_logger import ServiceLogging
|
||||
from litellm.constants import (
|
||||
CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
LITELLM_PROXY_MASTER_KEY_ALIAS,
|
||||
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
|
||||
|
|
@ -261,6 +262,7 @@ _UNTRUSTED_METADATA_CONTROL_FIELDS: Final = (
|
|||
"policy_sources",
|
||||
"routing_decision",
|
||||
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
|
||||
CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
"standard_logging_object",
|
||||
"proxy_server_request",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ PATCH /config/cost_margin_config - Update cost margin configuration
|
|||
POST /cost/estimate - Estimate cost for a given model and token counts
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
|
@ -24,29 +26,65 @@ from litellm.proxy._types import (
|
|||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.types.utils import LlmProvidersSet
|
||||
from litellm.types.utils import CostPerToken, LlmProvidersSet, ModelInfo
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
||||
|
||||
def _resolve_model_for_cost_lookup(model: str) -> tuple[str, str | None]:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedCostModel:
|
||||
model: str
|
||||
provider: str | None
|
||||
custom_cost_per_token: CostPerToken | None
|
||||
|
||||
|
||||
def _configured_price(key: str, sources: tuple[Mapping[str, object], ...]) -> float | None:
|
||||
values: Final = (source.get(key) for source in sources)
|
||||
numeric: Final = (float(value) for value in values if isinstance(value, (int, float)))
|
||||
return next(numeric, None)
|
||||
|
||||
|
||||
def _extract_custom_pricing(
|
||||
litellm_params: Mapping[str, object], model_info: Mapping[str, object]
|
||||
) -> CostPerToken | None:
|
||||
"""
|
||||
Pull per-token pricing configured on a deployment so on-prem / self-hosted
|
||||
models (absent from the public cost map) still estimate a real cost.
|
||||
Pricing may live on ``litellm_params`` or ``model_info``; ``litellm_params``
|
||||
wins, matching the router's cost-map registration precedence.
|
||||
"""
|
||||
sources: Final = (litellm_params, model_info)
|
||||
input_price: Final = _configured_price("input_cost_per_token", sources)
|
||||
output_price: Final = _configured_price("output_cost_per_token", sources)
|
||||
|
||||
if input_price is None and output_price is None:
|
||||
return None
|
||||
|
||||
return CostPerToken(
|
||||
input_cost_per_token=input_price or 0.0,
|
||||
output_cost_per_token=output_price or 0.0,
|
||||
)
|
||||
|
||||
|
||||
def _lookup_model_info(model: str) -> ModelInfo | None:
|
||||
try:
|
||||
return litellm.get_model_info(model=model)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_model_for_cost_lookup(model: str) -> ResolvedCostModel:
|
||||
"""
|
||||
Resolve a model name (which may be a router alias/model_group) to the
|
||||
underlying litellm model name for cost lookup.
|
||||
underlying litellm model name, provider, and any deployment-configured
|
||||
pricing used for cost lookup.
|
||||
|
||||
Args:
|
||||
model: The model name from the request (could be a router alias like 'e-model-router'
|
||||
or an actual model name like 'azure_ai/gpt-4')
|
||||
|
||||
Returns:
|
||||
Tuple of (resolved_model_name, custom_llm_provider)
|
||||
- resolved_model_name: The actual model name to use for cost lookup
|
||||
- custom_llm_provider: The provider if resolved from router, None otherwise
|
||||
"""
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
custom_llm_provider: str | None = None
|
||||
|
||||
# Try to resolve from router if available
|
||||
if llm_router is not None:
|
||||
try:
|
||||
|
|
@ -57,31 +95,25 @@ def _resolve_model_for_cost_lookup(model: str) -> tuple[str, str | None]:
|
|||
first_deployment: Final = deployments[0]
|
||||
litellm_params: Final = first_deployment.get("litellm_params", {})
|
||||
model_info: Final = first_deployment.get("model_info", {})
|
||||
custom_llm_provider: Final = litellm_params.get("custom_llm_provider")
|
||||
provider: Final = str(custom_llm_provider) if custom_llm_provider is not None else None
|
||||
custom_cost_per_token: Final = _extract_custom_pricing(litellm_params, model_info)
|
||||
|
||||
# Check base_model first (needed for Azure custom deployment names)
|
||||
base_model: Final = model_info.get("base_model") or litellm_params.get("base_model")
|
||||
if base_model:
|
||||
verbose_proxy_logger.debug("Resolved model '%s' to base_model '%s' from router", model, base_model)
|
||||
custom_llm_provider = litellm_params.get("custom_llm_provider")
|
||||
return (
|
||||
str(base_model),
|
||||
(str(custom_llm_provider) if custom_llm_provider is not None else None),
|
||||
)
|
||||
return ResolvedCostModel(str(base_model), provider, custom_cost_per_token)
|
||||
|
||||
resolved_model: Final = litellm_params.get("model")
|
||||
|
||||
if resolved_model:
|
||||
verbose_proxy_logger.debug("Resolved model '%s' to '%s' from router", model, resolved_model)
|
||||
custom_llm_provider = litellm_params.get("custom_llm_provider")
|
||||
return (
|
||||
str(resolved_model),
|
||||
(str(custom_llm_provider) if custom_llm_provider is not None else None),
|
||||
)
|
||||
return ResolvedCostModel(str(resolved_model), provider, custom_cost_per_token)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug("Could not resolve model '%s' from router: %s", model, e)
|
||||
|
||||
# Return original model if not resolved
|
||||
return model, custom_llm_provider
|
||||
return ResolvedCostModel(model, None, None)
|
||||
|
||||
|
||||
def _calculate_period_costs(num_requests, cost_per_request, input_cost, output_cost, margin_cost):
|
||||
|
|
@ -450,7 +482,9 @@ async def estimate_cost(
|
|||
from litellm.types.utils import ModelResponse, Usage
|
||||
|
||||
# Resolve model name (handles router aliases like 'e-model-router' -> 'azure_ai/gpt-4')
|
||||
resolved_model, resolved_provider = _resolve_model_for_cost_lookup(request.model)
|
||||
resolved: Final = _resolve_model_for_cost_lookup(request.model)
|
||||
resolved_model: Final = resolved.model
|
||||
resolved_provider: Final = resolved.provider
|
||||
|
||||
verbose_proxy_logger.debug("Cost estimate: request.model='%s' resolved to '%s'", request.model, resolved_model)
|
||||
|
||||
|
|
@ -480,6 +514,8 @@ async def estimate_cost(
|
|||
cost_per_request: Final = completion_cost(
|
||||
completion_response=mock_response,
|
||||
model=resolved_model,
|
||||
custom_llm_provider=resolved_provider,
|
||||
custom_cost_per_token=resolved.custom_cost_per_token,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -497,20 +533,22 @@ async def estimate_cost(
|
|||
output_cost: Final = cost_breakdown.get("output_cost", 0.0) if cost_breakdown else 0.0
|
||||
margin_cost: Final = cost_breakdown.get("margin_total_amount", 0.0) if cost_breakdown else 0.0
|
||||
|
||||
# Get model info for per-token pricing display
|
||||
try:
|
||||
model_info: Final = litellm.get_model_info(model=resolved_model)
|
||||
input_cost_per_token = model_info.get("input_cost_per_token")
|
||||
output_cost_per_token = model_info.get("output_cost_per_token")
|
||||
custom_llm_provider = model_info.get("litellm_provider")
|
||||
except Exception:
|
||||
input_cost_per_token = None
|
||||
output_cost_per_token = None
|
||||
custom_llm_provider = None
|
||||
model_info: Final = _lookup_model_info(resolved_model)
|
||||
mapped_input_price: Final = model_info.get("input_cost_per_token") if model_info is not None else None
|
||||
mapped_output_price: Final = model_info.get("output_cost_per_token") if model_info is not None else None
|
||||
mapped_provider: Final = model_info.get("litellm_provider") if model_info is not None else None
|
||||
|
||||
# Use provider from router resolution if not found in model_info
|
||||
if custom_llm_provider is None and resolved_provider is not None:
|
||||
custom_llm_provider = resolved_provider
|
||||
input_cost_per_token: Final = (
|
||||
resolved.custom_cost_per_token["input_cost_per_token"]
|
||||
if resolved.custom_cost_per_token is not None
|
||||
else mapped_input_price
|
||||
)
|
||||
output_cost_per_token: Final = (
|
||||
resolved.custom_cost_per_token["output_cost_per_token"]
|
||||
if resolved.custom_cost_per_token is not None
|
||||
else mapped_output_price
|
||||
)
|
||||
custom_llm_provider: Final = mapped_provider if mapped_provider is not None else resolved_provider
|
||||
|
||||
# Calculate daily and monthly costs
|
||||
(
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from litellm.proxy._types import (
|
|||
PrismaCompatibleUpdateDBModel,
|
||||
ProxyErrorTypes,
|
||||
ProxyException,
|
||||
ReconcileOutcome,
|
||||
TeamModelAddRequest,
|
||||
TeamModelDeleteRequest,
|
||||
UserAPIKeyAuth,
|
||||
|
|
@ -534,7 +535,7 @@ async def patch_model(
|
|||
|
||||
# Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates)
|
||||
live_before_reload: Final = live_model_ids_snapshot()
|
||||
still_desired_ids: Final = await clear_cache()
|
||||
reload_outcome: Final = await clear_cache()
|
||||
|
||||
## CREATE AUDIT LOG ##
|
||||
asyncio.create_task(
|
||||
|
|
@ -554,7 +555,8 @@ async def patch_model(
|
|||
before=live_before_reload,
|
||||
written_models=[(model_id, getattr(updated_model, "model_info", None))],
|
||||
action="update",
|
||||
still_desired=still_desired_ids,
|
||||
still_desired=reload_outcome.still_desired,
|
||||
live_after=reload_outcome.live_after,
|
||||
)
|
||||
|
||||
return updated_model
|
||||
|
|
@ -640,7 +642,7 @@ async def _set_model_blocked_status(
|
|||
)
|
||||
|
||||
live_before_reload: Final = live_model_ids_snapshot()
|
||||
still_desired_ids: Final = await clear_cache()
|
||||
reload_outcome: Final = await clear_cache()
|
||||
|
||||
asyncio.create_task(
|
||||
create_object_audit_log(
|
||||
|
|
@ -661,7 +663,8 @@ async def _set_model_blocked_status(
|
|||
before=live_before_reload,
|
||||
written_models=[(data.model_id, getattr(updated_model, "model_info", None))],
|
||||
action=action,
|
||||
still_desired=still_desired_ids,
|
||||
still_desired=reload_outcome.still_desired,
|
||||
live_after=reload_outcome.live_after,
|
||||
)
|
||||
|
||||
return updated_model
|
||||
|
|
@ -1033,9 +1036,15 @@ async def delete_team_models(
|
|||
if deleted_model_ids:
|
||||
await publish_config_change(redis_cache=coordination_redis_cache(), object_type="litellm_proxymodeltable")
|
||||
|
||||
# Under MODEL_RECONCILE_LOCK, for the same reason as delete_model: the rows are
|
||||
# gone, but a reconcile holding a pre-delete snapshot would upsert these ids back
|
||||
# onto this pod. The lock orders the eviction after any in-flight reconcile.
|
||||
if llm_router is not None:
|
||||
for model_id in deleted_model_ids:
|
||||
llm_router.delete_deployment(id=model_id)
|
||||
from litellm.proxy.proxy_server import MODEL_RECONCILE_LOCK
|
||||
|
||||
async with MODEL_RECONCILE_LOCK:
|
||||
for model_id in deleted_model_ids:
|
||||
llm_router.delete_deployment(id=model_id)
|
||||
|
||||
return deleted_model_ids
|
||||
|
||||
|
|
@ -1355,6 +1364,7 @@ async def delete_model(
|
|||
"""
|
||||
|
||||
from litellm.proxy.proxy_server import (
|
||||
MODEL_RECONCILE_LOCK,
|
||||
llm_router,
|
||||
premium_user,
|
||||
prisma_client,
|
||||
|
|
@ -1403,8 +1413,15 @@ async def delete_model(
|
|||
)
|
||||
|
||||
## DELETE FROM ROUTER ##
|
||||
# Under MODEL_RECONCILE_LOCK. The db row is already gone, but a reconcile
|
||||
# that snapshotted the db BEFORE that delete still lists this id as desired,
|
||||
# and its _add_deployment upserts the deployment straight back -- leaving
|
||||
# this pod serving a model the database no longer has, until the next
|
||||
# reconcile. Taking the lock orders this eviction after any such in-flight
|
||||
# reconcile's re-add, so the eviction is the last word.
|
||||
if llm_router is not None:
|
||||
llm_router.delete_deployment(id=model_info.id)
|
||||
async with MODEL_RECONCILE_LOCK:
|
||||
llm_router.delete_deployment(id=model_info.id)
|
||||
|
||||
# Runs after the row delete so the sibling check sees post-delete state.
|
||||
if model_params.model_info.team_id is not None:
|
||||
|
|
@ -1579,7 +1596,7 @@ async def add_new_model(
|
|||
"""
|
||||
|
||||
live_before_reload: Final = live_model_ids_snapshot()
|
||||
still_desired_ids: frozenset[str] | None = None
|
||||
reload_outcome: ReconcileOutcome = ReconcileOutcome(still_desired=None, live_after=None)
|
||||
try:
|
||||
_original_litellm_model_name: Final = model_params.model_name
|
||||
if model_params.model_info.team_id is None:
|
||||
|
|
@ -1594,7 +1611,7 @@ async def add_new_model(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
still_desired_ids = await proxy_config.add_deployment(
|
||||
reload_outcome = await proxy_config.add_deployment(
|
||||
prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj
|
||||
)
|
||||
# don't let failed slack alert block the /model/new response
|
||||
|
|
@ -1641,7 +1658,8 @@ async def add_new_model(
|
|||
before=live_before_reload,
|
||||
written_models=[(model_response.model_id, getattr(model_response, "model_info", None))],
|
||||
action="create",
|
||||
still_desired=still_desired_ids,
|
||||
still_desired=reload_outcome.still_desired,
|
||||
live_after=reload_outcome.live_after,
|
||||
)
|
||||
|
||||
return model_response
|
||||
|
|
@ -1768,7 +1786,7 @@ async def update_model(
|
|||
|
||||
# Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates)
|
||||
live_before_reload: Final = live_model_ids_snapshot()
|
||||
still_desired_ids: Final = await clear_cache()
|
||||
reload_outcome: Final = await clear_cache()
|
||||
## CREATE AUDIT LOG ##
|
||||
asyncio.create_task(
|
||||
create_object_audit_log(
|
||||
|
|
@ -1795,7 +1813,8 @@ async def update_model(
|
|||
before=live_before_reload,
|
||||
written_models=[(_model_id, getattr(model_response, "model_info", None))],
|
||||
action="update",
|
||||
still_desired=still_desired_ids,
|
||||
still_desired=reload_outcome.still_desired,
|
||||
live_after=reload_outcome.live_after,
|
||||
)
|
||||
|
||||
return model_response
|
||||
|
|
@ -2100,6 +2119,7 @@ def reload_serving_verdict(
|
|||
written_models: Sequence[tuple[str, object]],
|
||||
written_must_serve: bool,
|
||||
still_desired: frozenset[str] | None = None,
|
||||
live_after: frozenset[str] | None = None,
|
||||
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
"""Judge a write-triggered reload by diffing the router's serving state instead of
|
||||
trusting any layer of the reload stack to report its own failure.
|
||||
|
|
@ -2121,9 +2141,16 @@ def reload_serving_verdict(
|
|||
yet polled, so the reload dropping it is the reconcile working rather than damage.
|
||||
Without it (no reconcile ran) every drop is reported, which is the safe direction.
|
||||
|
||||
``live_after`` is the router's serving state captured by the reload itself, while it
|
||||
still held MODEL_RECONCILE_LOCK. Pass it whenever the caller has it: re-reading the
|
||||
router here instead means sampling it after the lock was released, where the NEXT
|
||||
reconcile's leading wipe (clear_cache un-serves every db model before reloading
|
||||
them) shows up as this reload having dropped them. Falling back to a fresh read is
|
||||
only correct when no reconcile ran and there is nothing to be concurrent with.
|
||||
|
||||
Returns (written ids violating their obligation, collateral ids no longer served).
|
||||
"""
|
||||
now: Final = live_model_ids_snapshot()
|
||||
now: Final = live_model_ids_snapshot() if live_after is None else live_after
|
||||
written_ids: Final = frozenset(model_id for model_id, _ in written_models)
|
||||
if written_must_serve:
|
||||
missing = tuple(
|
||||
|
|
@ -2143,16 +2170,23 @@ def raise_if_reload_degraded_serving(
|
|||
written_models: Sequence[tuple[str, object]],
|
||||
action: str,
|
||||
still_desired: frozenset[str] | None = None,
|
||||
live_after: frozenset[str] | None = None,
|
||||
) -> None:
|
||||
"""The caller-visible error this pod's model-write endpoints owe their caller when
|
||||
the model they wrote is not being served after the reload they triggered. The DB
|
||||
write is durable either way and every other pod reloads on its own interval; this
|
||||
speaks only for the handling pod."""
|
||||
speaks only for the handling pod.
|
||||
|
||||
Callers hold a ReconcileOutcome from the reload; pass BOTH of its fields. Supplying
|
||||
still_desired without live_after mixes a snapshot taken under the reconcile lock
|
||||
with one taken after it was released, which is what makes a concurrent model write
|
||||
look like collateral damage."""
|
||||
missing, collateral = reload_serving_verdict(
|
||||
before=before,
|
||||
written_models=written_models,
|
||||
written_must_serve=True,
|
||||
still_desired=still_desired,
|
||||
live_after=live_after,
|
||||
)
|
||||
if not missing and not collateral:
|
||||
return
|
||||
|
|
@ -2179,14 +2213,20 @@ def raise_if_reload_degraded_serving(
|
|||
)
|
||||
|
||||
|
||||
async def clear_cache() -> frozenset[str] | None:
|
||||
async def clear_cache() -> ReconcileOutcome:
|
||||
"""
|
||||
Clear router caches and reload models.
|
||||
|
||||
Returns the db + config id set the reload reconciled against, or None when no
|
||||
reload ran, so callers can pass it to raise_if_reload_degraded_serving.
|
||||
Returns what the reload saw (see ReconcileOutcome) so callers can pass it to
|
||||
raise_if_reload_degraded_serving.
|
||||
|
||||
Runs under MODEL_RECONCILE_LOCK for its whole extent, not just the reload at the
|
||||
end, so the auto-router reset and the reload that rebuilds those routers are atomic
|
||||
to any other reconcile. The inner call is _add_deployment_locked because
|
||||
add_deployment would re-acquire the same non-reentrant lock and deadlock.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import (
|
||||
MODEL_RECONCILE_LOCK,
|
||||
llm_router,
|
||||
prisma_client,
|
||||
proxy_config,
|
||||
|
|
@ -2196,61 +2236,88 @@ async def clear_cache() -> frozenset[str] | None:
|
|||
|
||||
if llm_router is None or prisma_client is None:
|
||||
verbose_proxy_logger.debug("llm_router or prisma_client is None, skipping cache clear")
|
||||
return None
|
||||
return ReconcileOutcome(still_desired=None, live_after=None)
|
||||
|
||||
try:
|
||||
# Only clear DB models, preserve config models
|
||||
verbose_proxy_logger.debug("Clearing only DB models, preserving config models")
|
||||
async with MODEL_RECONCILE_LOCK:
|
||||
try:
|
||||
# Only clear DB models, preserve config models
|
||||
verbose_proxy_logger.debug("Clearing only DB models, preserving config models")
|
||||
|
||||
# Get current models and filter out DB models
|
||||
current_models: Final = llm_router.model_list.copy()
|
||||
config_models: Final = []
|
||||
db_model_ids: Final = []
|
||||
# Get current models and filter out DB models
|
||||
current_models: Final = llm_router.model_list.copy()
|
||||
config_models: Final = []
|
||||
db_model_ids: Final = []
|
||||
|
||||
for model in current_models:
|
||||
model_info = model.get("model_info", {})
|
||||
if model_info.get("db_model", False):
|
||||
# This is a DB model, mark for deletion
|
||||
db_model_ids.append(model_info.get("id"))
|
||||
else:
|
||||
# This is a config model, preserve it
|
||||
config_models.append(model)
|
||||
db_router_names: Final = set()
|
||||
|
||||
# Clear only DB models
|
||||
for model_id in db_model_ids:
|
||||
llm_router.delete_deployment(id=model_id)
|
||||
for model in current_models:
|
||||
model_info = model.get("model_info", {})
|
||||
if model_info.get("db_model", False):
|
||||
db_model_ids.append(model_info.get("id"))
|
||||
# Auto-router deployments (and only those) are wiped here, in the
|
||||
# same pass, so the reload rebuilds them -- see the comment below.
|
||||
model_name = model.get("model_name")
|
||||
if model_name is not None and str(model.get("litellm_params", {}).get("model", "")).startswith(
|
||||
"auto_router/"
|
||||
):
|
||||
db_router_names.add(model_name)
|
||||
router_model_id = model_info.get("id")
|
||||
if router_model_id is not None:
|
||||
llm_router.delete_deployment(id=router_model_id)
|
||||
else:
|
||||
# This is a config model, preserved by the reconcile below
|
||||
config_models.append(model)
|
||||
|
||||
# Clear only DB-backed auto-router-family entries, keyed by model_name, so the
|
||||
# reload below rebuilds them fresh. A blanket .clear() would also drop config-defined
|
||||
# routers, which are never re-added below (add_deployment only reloads DB models),
|
||||
# leaving them permanently unroutable until a full proxy restart for every tenant.
|
||||
# Restrict to deployments whose model is actually an auto_router/* so a config
|
||||
# router that merely shares a model_name with a regular DB model isn't evicted. The
|
||||
# auto_router/ prefix also covers quality_router/ and adaptive_router/, so pop the
|
||||
# name from every router registry (no-op where absent); missing quality/adaptive
|
||||
# entries would otherwise make init raise "already exists" on reload and abort it.
|
||||
db_router_names: Final = {
|
||||
model.get("model_name")
|
||||
for model in current_models
|
||||
if model.get("model_name") is not None
|
||||
and model.get("model_info", {}).get("db_model", False)
|
||||
and str(model.get("litellm_params", {}).get("model", "")).startswith("auto_router/")
|
||||
}
|
||||
for model_name in db_router_names:
|
||||
llm_router.auto_routers.pop(model_name, None)
|
||||
llm_router.complexity_routers.pop(model_name, None)
|
||||
llm_router.adaptive_routers.pop(model_name, None)
|
||||
llm_router.quality_routers.pop(model_name, None)
|
||||
# ORDINARY db deployments are deliberately NOT wiped. This used to
|
||||
# delete_deployment() every db model before the reload put them back, which
|
||||
# left the router serving ZERO db models for the whole width of the reload
|
||||
# -- a real data-plane hole that every inference request landing in it fell
|
||||
# into. It was also redundant for them: the reload's _delete_deployment
|
||||
# evicts exactly the ids the db no longer lists, and upsert_deployment
|
||||
# pops-and-re-adds a deployment whose params changed while no-opping one
|
||||
# that did not, so the reconcile converges on its own. Every mutation is
|
||||
# visible to that comparison -- `blocked` and (for premium) `updated_at`
|
||||
# are written into model_info.
|
||||
#
|
||||
# AUTO-ROUTER db deployments are the exception and ARE wiped -- in the
|
||||
# classification pass above, together with the strategy entries popped
|
||||
# just below. Their strategy registries are keyed
|
||||
# by model_name, which no deployment-id reconcile touches, so they have to
|
||||
# be popped and rebuilt here. But the rebuild only happens on the ADD path:
|
||||
# Router.upsert_deployment returns early when a deployment is unchanged and
|
||||
# never reaches add_deployment -> _add_deployment ->
|
||||
# init_auto_router_deployment, which is what repopulates the registries.
|
||||
# Popping without deleting would therefore strip every db-backed auto,
|
||||
# complexity, adaptive and quality router on this pod and never put it back,
|
||||
# so ANY unrelated model write would leave them unroutable until a restart.
|
||||
# Deleting the deployment forces upsert down the add path, which rebuilds
|
||||
# both the deployment and its strategy entry.
|
||||
#
|
||||
# That pass restricts the wipe to deployments whose model is actually an
|
||||
# auto_router/* so a config router that merely shares a model_name with a
|
||||
# regular db model isn't evicted -- config routers are never re-added by the
|
||||
# reload (it only reloads db models) and would be permanently unroutable.
|
||||
# The auto_router/ prefix also covers quality_router/ and adaptive_router/,
|
||||
# so pop the name from every registry (no-op where absent); a missing
|
||||
# quality/adaptive entry would otherwise make init raise "already exists"
|
||||
# on reload and abort it.
|
||||
for model_name in db_router_names:
|
||||
llm_router.auto_routers.pop(model_name, None)
|
||||
llm_router.complexity_routers.pop(model_name, None)
|
||||
llm_router.adaptive_routers.pop(model_name, None)
|
||||
llm_router.quality_routers.pop(model_name, None)
|
||||
|
||||
# Reload only DB models
|
||||
still_desired_ids: Final = await proxy_config.add_deployment(
|
||||
prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj
|
||||
)
|
||||
# Reload only DB models. _add_deployment_locked, not add_deployment: this
|
||||
# coroutine already holds MODEL_RECONCILE_LOCK and asyncio.Lock is not
|
||||
# reentrant, so the public wrapper would deadlock against itself.
|
||||
outcome: Final = await proxy_config._add_deployment_locked(
|
||||
prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"Cleared %s DB models, preserved %s config models", len(db_model_ids), len(config_models)
|
||||
)
|
||||
return still_desired_ids
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Failed to clear cache and reload models. Due to error - %s", e)
|
||||
return None
|
||||
verbose_proxy_logger.debug(
|
||||
"Reconciled %s DB models, preserved %s config models", len(db_model_ids), len(config_models)
|
||||
)
|
||||
return outcome
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Failed to clear cache and reload models. Due to error - %s", e)
|
||||
return ReconcileOutcome(still_desired=None, live_after=None)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
|
|
@ -7,6 +8,7 @@ import httpx
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import ANTHROPIC_BATCHES_ROUTE
|
||||
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
|
||||
|
|
@ -20,6 +22,12 @@ from litellm.llms.anthropic.chat.handler import (
|
|||
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.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import (
|
||||
is_collection_route,
|
||||
log_batch_registration_result,
|
||||
optional_str,
|
||||
request_tags_from_metadata,
|
||||
)
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
PassthroughStandardLoggingPayload,
|
||||
)
|
||||
|
|
@ -74,6 +82,9 @@ class AnthropicPassthroughLoggingHandler:
|
|||
)
|
||||
|
||||
model: Final = response_body.get("model", "")
|
||||
speed: Final = AnthropicPassthroughLoggingHandler._cost_relevant_speed(
|
||||
request_body or kwargs.get("request_body")
|
||||
)
|
||||
anthropic_config: Final = get_anthropic_config(url_route)
|
||||
litellm_model_response: Final[ModelResponse] = anthropic_config().transform_response(
|
||||
raw_response=httpx_response,
|
||||
|
|
@ -81,7 +92,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
model=model,
|
||||
messages=[],
|
||||
logging_obj=logging_obj,
|
||||
optional_params={},
|
||||
optional_params={"speed": speed} if speed else {},
|
||||
api_key="",
|
||||
request_data={},
|
||||
encoding=litellm.encoding,
|
||||
|
|
@ -103,6 +114,15 @@ class AnthropicPassthroughLoggingHandler:
|
|||
"kwargs": kwargs,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _cost_relevant_speed(request_body: Mapping[str, object] | None) -> str | None:
|
||||
"""
|
||||
Anthropic's ``speed=fast`` multiplies non-cache token cost, and only the request
|
||||
carries it, so it has to reach the usage-building paths for spend to be right.
|
||||
"""
|
||||
speed: Final = (request_body or {}).get("speed")
|
||||
return speed if isinstance(speed, str) else None
|
||||
|
||||
@staticmethod
|
||||
def _get_user_from_metadata(
|
||||
passthrough_logging_payload: PassthroughStandardLoggingPayload,
|
||||
|
|
@ -316,6 +336,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
- Logs in litellm callbacks
|
||||
"""
|
||||
|
||||
speed: Final = AnthropicPassthroughLoggingHandler._cost_relevant_speed(request_body)
|
||||
model = request_body.get("model", "")
|
||||
# Check if it's available in the logging object
|
||||
if (
|
||||
|
|
@ -335,6 +356,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
all_chunks=all_chunks,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
model=model,
|
||||
speed=speed,
|
||||
)
|
||||
except Exception as e:
|
||||
# stream_chunk_builder re-raises assembly failures (as litellm.APIError)
|
||||
|
|
@ -356,6 +378,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
complete_streaming_response = AnthropicPassthroughLoggingHandler._build_usage_only_response_from_chunks(
|
||||
all_chunks=all_chunks,
|
||||
model=model,
|
||||
speed=speed,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
|
|
@ -420,6 +443,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
all_chunks: Sequence[str | bytes],
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
model: str,
|
||||
speed: str | None = None,
|
||||
) -> ModelResponse | TextCompletionResponse | None:
|
||||
"""
|
||||
Builds complete response from raw Anthropic chunks.
|
||||
|
|
@ -444,11 +468,13 @@ class AnthropicPassthroughLoggingHandler:
|
|||
all_chunks=collapsed,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
model=model,
|
||||
speed=speed,
|
||||
)
|
||||
return AnthropicPassthroughLoggingHandler._build_complete_streaming_response_legacy(
|
||||
all_chunks=all_chunks,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
model=model,
|
||||
speed=speed,
|
||||
)
|
||||
|
||||
# Anthropic SSE block/delta types that the fast path is NOT allowed to
|
||||
|
|
@ -576,6 +602,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
all_chunks: Sequence[str | bytes],
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
model: str,
|
||||
speed: str | None = None,
|
||||
) -> ModelResponse | TextCompletionResponse | None:
|
||||
"""
|
||||
Original reconstruction: convert every SSE event to a generic chunk
|
||||
|
|
@ -591,6 +618,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
anthropic_model_response_iterator: Final = AnthropicModelResponseIterator(
|
||||
streaming_response=None,
|
||||
sync_stream=False,
|
||||
speed=speed,
|
||||
)
|
||||
all_openai_chunks: Final = []
|
||||
|
||||
|
|
@ -650,6 +678,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
def _build_usage_only_response_from_chunks(
|
||||
all_chunks: Sequence[str | bytes],
|
||||
model: str,
|
||||
speed: str | None = None,
|
||||
) -> ModelResponse | None:
|
||||
"""
|
||||
Build a usage-bearing ModelResponse from Anthropic SSE token-usage events, for
|
||||
|
|
@ -743,7 +772,9 @@ class AnthropicPassthroughLoggingHandler:
|
|||
usage_object["server_tool_use"] = _server_tool_use
|
||||
if inference_geo is not None:
|
||||
usage_object["inference_geo"] = inference_geo
|
||||
usage_obj: Final = AnthropicConfig().calculate_usage(usage_object=usage_object, reasoning_content=None)
|
||||
usage_obj: Final = AnthropicConfig().calculate_usage(
|
||||
usage_object=usage_object, reasoning_content=None, speed=speed
|
||||
)
|
||||
return ModelResponse(
|
||||
model=resolved_model,
|
||||
choices=[
|
||||
|
|
@ -833,13 +864,14 @@ class AnthropicPassthroughLoggingHandler:
|
|||
|
||||
# Store the managed object for cost tracking
|
||||
# This will be picked up by check_batch_cost polling mechanism
|
||||
AnthropicPassthroughLoggingHandler._store_batch_managed_object(
|
||||
unified_object_id=unified_object_id,
|
||||
batch_object=litellm_batch_response,
|
||||
model_object_id=batch_id,
|
||||
logging_obj=logging_obj,
|
||||
**kwargs,
|
||||
)
|
||||
if is_collection_route(url_route, ANTHROPIC_BATCHES_ROUTE):
|
||||
AnthropicPassthroughLoggingHandler._store_batch_managed_object(
|
||||
unified_object_id=unified_object_id,
|
||||
batch_object=litellm_batch_response,
|
||||
model_object_id=batch_id,
|
||||
logging_obj=logging_obj,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Create a batch job response for logging
|
||||
litellm_model_response = ModelResponse()
|
||||
|
|
@ -964,8 +996,12 @@ class AnthropicPassthroughLoggingHandler:
|
|||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Store batch managed object for cost tracking.
|
||||
Register a newly created batch for cost tracking.
|
||||
This will be picked up by the check_batch_cost polling mechanism.
|
||||
|
||||
Only the create reaches here, so the row records the creating key and its tags.
|
||||
An id-scoped route cannot rebuild the unified object id anyway: the model comes
|
||||
from the create's request body, which a retrieve does not have.
|
||||
"""
|
||||
try:
|
||||
# Get the managed files hook from the logging object
|
||||
|
|
@ -981,7 +1017,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
|
||||
user_api_key_dict: Final = UserAPIKeyAuth(
|
||||
user_id=_request_metadata.get("user_api_key_user_id", "default-user"),
|
||||
api_key="",
|
||||
api_key=optional_str(_request_metadata.get("user_api_key")),
|
||||
team_id=_request_metadata.get("user_api_key_team_id"),
|
||||
team_alias=None,
|
||||
user_role=LitellmUserRoles.CUSTOMER, # Use proper enum value
|
||||
|
|
@ -1003,9 +1039,7 @@ class AnthropicPassthroughLoggingHandler:
|
|||
)
|
||||
|
||||
# Store the unified object for batch cost tracking
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(
|
||||
task: Final = asyncio.create_task(
|
||||
managed_files_hook.store_unified_object_id(
|
||||
unified_object_id=unified_object_id,
|
||||
file_object=batch_object,
|
||||
|
|
@ -1013,13 +1047,14 @@ class AnthropicPassthroughLoggingHandler:
|
|||
model_object_id=model_object_id,
|
||||
file_purpose="batch",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_tags=request_tags_from_metadata(_request_metadata),
|
||||
persist_attribution=True,
|
||||
)
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
"Stored Anthropic batch managed object with unified_object_id=%s, batch_id=%s",
|
||||
unified_object_id,
|
||||
model_object_id,
|
||||
task.add_done_callback(
|
||||
lambda finished: log_batch_registration_result(
|
||||
finished, "Anthropic", unified_object_id, model_object_id, is_batch_create=True
|
||||
)
|
||||
)
|
||||
else:
|
||||
verbose_proxy_logger.warning(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
"""Spend attribution for batches created through a passthrough endpoint.
|
||||
|
||||
The creating key and its tags are read off the passthrough request's metadata and
|
||||
persisted on the managed object row, because the batch cost lands hours later in a
|
||||
background poll that has no request to read them from.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.litellm_core_utils.safe_json_dumps import strip_null_bytes
|
||||
|
||||
|
||||
def optional_str(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _sanitized_str_tuple(value: object) -> tuple[str, ...] | None:
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
items: Final[Sequence[object]] = value
|
||||
return tuple(strip_null_bytes(tag) for tag in items if isinstance(tag, str))
|
||||
|
||||
|
||||
def is_collection_route(url_route: str, collection_suffix: str) -> bool:
|
||||
"""Whether the route addresses the batch collection itself rather than one batch.
|
||||
A POST to the collection is the create; every id-scoped route is a retrieve,
|
||||
results or cancel.
|
||||
"""
|
||||
return url_route.split("?")[0].rstrip("/").endswith(collection_suffix)
|
||||
|
||||
|
||||
def request_tags_from_metadata(request_metadata: Mapping[str, object]) -> tuple[str, ...] | None:
|
||||
"""Tags for the batch-cost spend row: the request's own tags when it sent any,
|
||||
otherwise the key's tags, which auth exposes as user_api_key_auth_metadata (a
|
||||
tagged key does not put its tags in the top-level metadata "tags" on the
|
||||
passthrough path)
|
||||
"""
|
||||
tags: Final = _sanitized_str_tuple(request_metadata.get("tags"))
|
||||
if tags:
|
||||
return tags
|
||||
key_auth_metadata: Final = request_metadata.get("user_api_key_auth_metadata")
|
||||
if isinstance(key_auth_metadata, dict):
|
||||
return _sanitized_str_tuple(key_auth_metadata.get("tags"))
|
||||
return None
|
||||
|
||||
|
||||
def log_batch_registration_result(
|
||||
finished: asyncio.Task[None],
|
||||
provider: str,
|
||||
unified_object_id: str,
|
||||
model_object_id: str,
|
||||
is_batch_create: bool,
|
||||
) -> None:
|
||||
"""Report the outcome of the fire-and-forget managed object write. A create that
|
||||
fails is not retried by a later poll, so its cost is never tracked at all.
|
||||
"""
|
||||
error: Final = finished.exception() if not finished.cancelled() else None
|
||||
if finished.cancelled() or error is not None:
|
||||
consequence: Final = (
|
||||
"its cost will not be tracked" if is_batch_create else "its status and output file may be stale"
|
||||
)
|
||||
verbose_proxy_logger.error(
|
||||
"Failed to store %s batch managed object with unified_object_id=%s, batch_id=%s; %s: %s",
|
||||
provider,
|
||||
unified_object_id,
|
||||
model_object_id,
|
||||
consequence,
|
||||
error,
|
||||
)
|
||||
return
|
||||
verbose_proxy_logger.info(
|
||||
"Stored %s batch managed object with unified_object_id=%s, batch_id=%s",
|
||||
provider,
|
||||
unified_object_id,
|
||||
model_object_id,
|
||||
)
|
||||
|
|
@ -464,7 +464,7 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
|
||||
def _build_complete_streaming_response(
|
||||
self,
|
||||
all_chunks: list,
|
||||
all_chunks: list[str],
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
model: str,
|
||||
) -> ModelResponse | TextCompletionResponse | None:
|
||||
|
|
@ -536,13 +536,19 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
# Extract model from request body
|
||||
model: Final = request_body.get("model", "gpt-4o")
|
||||
|
||||
is_responses: Final = OpenAIPassthroughLoggingHandler.is_openai_responses_route(url_route)
|
||||
|
||||
# Build complete response from chunks using our streaming handler
|
||||
handler: Final = OpenAIPassthroughLoggingHandler()
|
||||
handler_instance: Final = handler
|
||||
complete_response: Final = handler._build_complete_streaming_response(
|
||||
all_chunks=all_chunks,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
model=model,
|
||||
complete_response: Final = (
|
||||
OpenAIResponsesAPIConfig.parse_terminal_response_from_stream_chunks(all_chunks=all_chunks)
|
||||
if is_responses
|
||||
else handler._build_complete_streaming_response(
|
||||
all_chunks=all_chunks,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
model=model,
|
||||
)
|
||||
)
|
||||
|
||||
if complete_response is None:
|
||||
|
|
@ -554,10 +560,19 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
|
||||
custom_llm_provider: Final = litellm_logging_obj.model_call_details.get("custom_llm_provider", "openai")
|
||||
# Calculate cost using LiteLLM's cost calculator
|
||||
response_cost: Final = litellm.completion_cost(
|
||||
completion_response=complete_response,
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
response_cost: Final = (
|
||||
litellm.completion_cost(
|
||||
completion_response=complete_response,
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
call_type="responses",
|
||||
)
|
||||
if is_responses
|
||||
else litellm.completion_cost(
|
||||
completion_response=complete_response,
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
)
|
||||
|
||||
# Preserve existing litellm_params to maintain metadata tags
|
||||
|
|
@ -568,6 +583,8 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
"response_cost": response_cost,
|
||||
"model": model,
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
"call_type": litellm_logging_obj.call_type,
|
||||
"messages": litellm_logging_obj.model_call_details.get("messages"),
|
||||
"litellm_params": existing_litellm_params.copy(),
|
||||
}
|
||||
|
||||
|
|
@ -584,8 +601,11 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
user
|
||||
)
|
||||
|
||||
# Create standard logging object
|
||||
get_standard_logging_object_payload(
|
||||
# Attach the payload to kwargs so the success handler adopts it;
|
||||
# its later rebuild runs on a copy whose Responses usage was
|
||||
# coerced to chat shape and serializes as total_tokens only,
|
||||
# zeroing the prompt/completion split in spend logs.
|
||||
standard_logging_object: Final = get_standard_logging_object_payload(
|
||||
kwargs=kwargs,
|
||||
init_response_obj=complete_response,
|
||||
start_time=start_time,
|
||||
|
|
@ -593,6 +613,8 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
|
|||
logging_obj=litellm_logging_obj,
|
||||
status="success",
|
||||
)
|
||||
if standard_logging_object is not None:
|
||||
kwargs["standard_logging_object"] = standard_logging_object
|
||||
|
||||
# Update logging object with cost information
|
||||
litellm_logging_obj.model_call_details["model"] = model
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import asyncio
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -9,6 +8,7 @@ import httpx
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import VERTEX_BATCH_PREDICTION_JOBS_ROUTE
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
ModelResponseIterator as VertexModelResponseIterator,
|
||||
|
|
@ -18,6 +18,12 @@ from litellm.llms.vertex_ai.vector_stores.search_api.transformation import (
|
|||
)
|
||||
from litellm.llms.vertex_ai.videos.transformation import VertexAIVideoConfig
|
||||
from litellm.proxy._types import PassThroughEndpointLoggingTypedDict
|
||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import (
|
||||
is_collection_route,
|
||||
log_batch_registration_result,
|
||||
optional_str,
|
||||
request_tags_from_metadata,
|
||||
)
|
||||
from litellm.types.utils import (
|
||||
Choices,
|
||||
EmbeddingResponse,
|
||||
|
|
@ -41,32 +47,6 @@ else:
|
|||
EndpointType = Any
|
||||
|
||||
|
||||
def _optional_str(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _optional_str_tuple(value: object) -> tuple[str, ...] | None:
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
items: Final = cast(list[object], value) # cast-ok: isinstance-narrowed; element type unknown
|
||||
return tuple(tag for tag in items if isinstance(tag, str))
|
||||
|
||||
|
||||
def _request_tags(request_metadata: Mapping[str, object]) -> tuple[str, ...] | None:
|
||||
"""Tags for the batch-cost spend row: the request's own tags when it sent any,
|
||||
otherwise the key's tags, which auth exposes as user_api_key_auth_metadata (a
|
||||
tagged key does not put its tags in the top-level metadata "tags" on the
|
||||
passthrough path)
|
||||
"""
|
||||
tags: Final = _optional_str_tuple(request_metadata.get("tags"))
|
||||
if tags:
|
||||
return tags
|
||||
key_auth_metadata: Final = request_metadata.get("user_api_key_auth_metadata")
|
||||
if isinstance(key_auth_metadata, dict):
|
||||
return _optional_str_tuple(key_auth_metadata.get("tags"))
|
||||
return None
|
||||
|
||||
|
||||
class VertexPassthroughLoggingHandler:
|
||||
@staticmethod
|
||||
def vertex_passthrough_handler(
|
||||
|
|
@ -685,7 +665,7 @@ class VertexPassthroughLoggingHandler:
|
|||
|
||||
# Store the managed object for cost tracking
|
||||
# This will be picked up by check_batch_cost polling mechanism
|
||||
is_batch_create: Final = url_route.split("?")[0].rstrip("/").endswith("batchPredictionJobs")
|
||||
is_batch_create: Final = is_collection_route(url_route, VERTEX_BATCH_PREDICTION_JOBS_ROUTE)
|
||||
VertexPassthroughLoggingHandler._store_batch_managed_object(
|
||||
unified_object_id=unified_object_id,
|
||||
batch_object=litellm_batch_response,
|
||||
|
|
@ -809,29 +789,6 @@ class VertexPassthroughLoggingHandler:
|
|||
"kwargs": kwargs,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _log_batch_registration_result(
|
||||
finished: asyncio.Task, unified_object_id: str, model_object_id: str, is_batch_create: bool
|
||||
) -> None:
|
||||
error: Final = finished.exception() if not finished.cancelled() else None
|
||||
if finished.cancelled() or error is not None:
|
||||
consequence: Final = (
|
||||
"its cost will not be tracked" if is_batch_create else "its status and output file may be stale"
|
||||
)
|
||||
verbose_proxy_logger.error(
|
||||
"Failed to store batch managed object with unified_object_id=%s, batch_id=%s; %s: %s",
|
||||
unified_object_id,
|
||||
model_object_id,
|
||||
consequence,
|
||||
error,
|
||||
)
|
||||
return
|
||||
verbose_proxy_logger.info(
|
||||
"Stored batch managed object with unified_object_id=%s, batch_id=%s",
|
||||
unified_object_id,
|
||||
model_object_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _store_batch_managed_object(
|
||||
unified_object_id: str,
|
||||
|
|
@ -863,7 +820,7 @@ class VertexPassthroughLoggingHandler:
|
|||
|
||||
user_api_key_dict: Final = UserAPIKeyAuth(
|
||||
user_id=_request_metadata.get("user_api_key_user_id", "default-user"),
|
||||
api_key=_optional_str(_request_metadata.get("user_api_key")),
|
||||
api_key=optional_str(_request_metadata.get("user_api_key")),
|
||||
team_id=_request_metadata.get("user_api_key_team_id"),
|
||||
team_alias=None,
|
||||
user_role=LitellmUserRoles.CUSTOMER, # Use proper enum value
|
||||
|
|
@ -893,14 +850,14 @@ class VertexPassthroughLoggingHandler:
|
|||
model_object_id=model_object_id,
|
||||
file_purpose="batch",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_tags=_request_tags(_request_metadata),
|
||||
request_tags=request_tags_from_metadata(_request_metadata),
|
||||
persist_attribution=is_batch_create,
|
||||
create_if_missing=is_batch_create,
|
||||
)
|
||||
)
|
||||
task.add_done_callback(
|
||||
lambda finished: VertexPassthroughLoggingHandler._log_batch_registration_result(
|
||||
finished, unified_object_id, model_object_id, is_batch_create
|
||||
lambda finished: log_batch_registration_result(
|
||||
finished, "Vertex AI", unified_object_id, model_object_id, is_batch_create
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -233,15 +233,7 @@ async def chat_completion_pass_through_endpoint(
|
|||
# skip router if user passed their key
|
||||
if "api_key" in data:
|
||||
llm_response = asyncio.create_task(litellm.aadapter_completion(**data))
|
||||
elif llm_router is not None and data["model"] in router_model_names: # model in router model list
|
||||
llm_response = asyncio.create_task(llm_router.aadapter_completion(**data))
|
||||
elif (
|
||||
llm_router is not None
|
||||
and llm_router.model_group_alias is not None
|
||||
and data["model"] in llm_router.model_group_alias
|
||||
): # model set in model_group_alias
|
||||
llm_response = asyncio.create_task(llm_router.aadapter_completion(**data))
|
||||
elif llm_router is not None and llm_router.has_model_id(data["model"]): # model in router model list
|
||||
elif llm_router is not None and llm_router.is_recognized_model(data["model"]):
|
||||
llm_response = asyncio.create_task(llm_router.aadapter_completion(**data))
|
||||
elif (
|
||||
llm_router is not None
|
||||
|
|
@ -565,6 +557,7 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
# real parent span.
|
||||
_metadata["user_api_key"] = user_api_key_dict.api_key
|
||||
_metadata["litellm_parent_otel_span"] = user_api_key_dict.parent_otel_span
|
||||
_metadata["user_api_key_budget_reservation"] = user_api_key_dict.budget_reservation
|
||||
_metadata.update(
|
||||
LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(user_api_key_dict=user_api_key_dict)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ try:
|
|||
import orjson
|
||||
import yaml
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
except ImportError as e:
|
||||
raise ImportError(f"Missing dependency {e}. Run `pip install 'litellm[proxy]'`")
|
||||
|
||||
|
|
@ -344,6 +345,12 @@ from litellm.proxy.common_utils.periodic_reload_schedule import (
|
|||
)
|
||||
from litellm.proxy.common_utils.proxy_state import ProxyState
|
||||
from litellm.proxy.common_utils.reset_budget_job import ResetBudgetJob
|
||||
from litellm.proxy.common_utils.scheduled_job_stagger import (
|
||||
apply_scheduled_job_stagger,
|
||||
attach_job_timing_logger,
|
||||
parse_stagger_settings,
|
||||
stagger_trigger,
|
||||
)
|
||||
from litellm.proxy.common_utils.swagger_utils import ERROR_RESPONSES
|
||||
from litellm.proxy.common_utils.timezone_utils import (
|
||||
get_budget_reset_settings,
|
||||
|
|
@ -460,6 +467,7 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
|
|||
_add_model_to_db,
|
||||
_add_team_model_to_db,
|
||||
_deduplicate_litellm_router_models,
|
||||
live_model_ids_snapshot,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
router as model_management_router,
|
||||
|
|
@ -837,6 +845,22 @@ def cleanup_router_config_variables():
|
|||
prisma_client = None
|
||||
|
||||
|
||||
async def _flush_spend_logs_queue_on_shutdown() -> None:
|
||||
if prisma_client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
from litellm.proxy.utils import drain_spend_logs_queue
|
||||
|
||||
await drain_spend_logs_queue(
|
||||
prisma_client=prisma_client,
|
||||
db_writer_client=db_writer_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # shutdown must continue even if the drain fails
|
||||
verbose_proxy_logger.exception("Error flushing spend logs queue on shutdown: %s", e)
|
||||
|
||||
|
||||
async def proxy_shutdown_event():
|
||||
global prisma_client, master_key, user_custom_auth, user_custom_key_generate, user_custom_key_update
|
||||
verbose_proxy_logger.info("Shutting down LiteLLM Proxy Server")
|
||||
|
|
@ -1247,6 +1271,8 @@ async def proxy_startup_event(app: FastAPI):
|
|||
except Exception as e:
|
||||
verbose_proxy_logger.error("Error stopping DB health watchdog task: %s", e)
|
||||
|
||||
await _flush_spend_logs_queue_on_shutdown()
|
||||
|
||||
await proxy_config.stop_config_sync_subscriber()
|
||||
|
||||
await proxy_config.stop_auth_cache_invalidation_subscriber()
|
||||
|
|
@ -2159,6 +2185,15 @@ experimental = False
|
|||
#### GLOBAL VARIABLES ####
|
||||
llm_router: Router | None = None
|
||||
llm_model_list: list | None = None
|
||||
# Serializes every model reconcile (ProxyConfig.add_deployment and clear_cache) so the
|
||||
# read-modify-write of llm_router above is atomic. Without it, two concurrent model
|
||||
# writes each reconcile the router against their OWN db snapshot, and the one holding
|
||||
# the older snapshot evicts the deployment the newer one just added -- the db keeps the
|
||||
# row, this pod stops serving it. Control-plane only (model create/update/delete and
|
||||
# the config-sync tick), never on a completion path, so the serialization is free.
|
||||
# Module-level rather than per-ProxyConfig because llm_router is a module global and a
|
||||
# second ProxyConfig instance must not get its own independent lock over it.
|
||||
MODEL_RECONCILE_LOCK: Final = asyncio.Lock()
|
||||
general_settings: dict = {}
|
||||
config_passthrough_endpoints: list[dict[str, Any]] | None = None
|
||||
log_file: Final = "api_log.json"
|
||||
|
|
@ -6142,10 +6177,17 @@ class ProxyConfig:
|
|||
retention_interval: Final = general_settings.get("maximum_spend_logs_retention_interval", "1d")
|
||||
try:
|
||||
interval_seconds: Final = duration_in_seconds(retention_interval)
|
||||
# this runs against a started scheduler, which the startup stagger sweep
|
||||
# cannot reach, so the offset is applied here or the job reconverges across
|
||||
# replicas the first time an admin edits the retention settings
|
||||
scheduler.add_job(
|
||||
spend_log_cleanup.cleanup_old_spend_logs,
|
||||
"interval",
|
||||
seconds=interval_seconds + random.randint(0, 60),
|
||||
stagger_trigger(
|
||||
job_id="spend_log_cleanup_job",
|
||||
trigger=IntervalTrigger(seconds=interval_seconds),
|
||||
period_seconds=interval_seconds,
|
||||
settings=parse_stagger_settings(general_settings),
|
||||
),
|
||||
args=[prisma_client],
|
||||
id="spend_log_cleanup_job",
|
||||
replace_existing=True,
|
||||
|
|
@ -6442,16 +6484,37 @@ class ProxyConfig:
|
|||
self,
|
||||
prisma_client: PrismaClient,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> frozenset[str] | None:
|
||||
) -> ReconcileOutcome:
|
||||
"""
|
||||
- Check db for new models
|
||||
- Check if model id's in router already
|
||||
- If not, add to router
|
||||
|
||||
Returns the ids the db + config say should be served after the reconcile, or
|
||||
None when no reconcile ran. Callers that judge their own reload need it to tell
|
||||
a deliberate eviction from a deployment that went missing.
|
||||
Serialized against every other model reconcile by MODEL_RECONCILE_LOCK, because
|
||||
the work below is a read-modify-write of the shared ``llm_router`` global: it
|
||||
reads the db into a snapshot and then makes the router match that snapshot. Two
|
||||
of those interleaving is not a lost update but an eviction -- the request whose
|
||||
snapshot predates the other's commit reconciles the newer model *out* of the
|
||||
router, since _delete_deployment removes every live deployment absent from the
|
||||
snapshot it was handed. The model stays in the db and this pod stops serving it
|
||||
until some later reload puts it back.
|
||||
|
||||
Returns what the reconcile saw, captured before the lock is released so a
|
||||
caller's verdict cannot be corrupted by the next reconcile's own in-flight
|
||||
window. See ReconcileOutcome.
|
||||
"""
|
||||
async with MODEL_RECONCILE_LOCK:
|
||||
return await self._add_deployment_locked(prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj)
|
||||
|
||||
async def _add_deployment_locked(
|
||||
self,
|
||||
prisma_client: PrismaClient,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> ReconcileOutcome:
|
||||
"""add_deployment's body, minus the locking. MODEL_RECONCILE_LOCK MUST already
|
||||
be held. Split out for the one caller that has to hold the lock across more than
|
||||
this reconcile -- clear_cache, which un-serves every db model before calling it
|
||||
and would deadlock on a re-acquire."""
|
||||
global llm_router, llm_model_list, master_key, general_settings
|
||||
|
||||
still_desired_ids: frozenset[str] | None = None
|
||||
|
|
@ -6494,7 +6557,12 @@ class ProxyConfig:
|
|||
except Exception as e:
|
||||
verbose_proxy_logger.exception("litellm.proxy.proxy_server.py::ProxyConfig:add_deployment - %s", e)
|
||||
|
||||
return still_desired_ids
|
||||
# Read while the lock is still held: once it is released the next reconcile can
|
||||
# begin, and clear_cache's leading wipe would make this look like a mass drop.
|
||||
return ReconcileOutcome(
|
||||
still_desired=still_desired_ids,
|
||||
live_after=None if still_desired_ids is None else live_model_ids_snapshot(),
|
||||
)
|
||||
|
||||
def start_config_sync_subscriber(
|
||||
self,
|
||||
|
|
@ -7865,8 +7933,17 @@ def _resolve_keepalive_seconds(request_data: Mapping[str, Any], response: object
|
|||
# keepalive_seconds is operator-only unless the deployment explicitly opts in:
|
||||
# a client can't unilaterally enable heartbeats (and the LB-idle-timeout
|
||||
# evasion that comes with them) for a deployment that never configured this.
|
||||
# When neither the request nor the deployment sets a value, the operator's
|
||||
# global `litellm_settings.sse_keepalive_ping_interval_seconds` applies; a
|
||||
# deployment's explicit `keepalive_seconds: 0` above still hard-disables it.
|
||||
client_supplied: Final = request_data.get("keepalive_seconds") if allow_client_override else None
|
||||
raw: Final = client_supplied if client_supplied is not None else deployment_raw
|
||||
raw: Final = (
|
||||
client_supplied
|
||||
if client_supplied is not None
|
||||
else deployment_raw
|
||||
if deployment_raw is not None
|
||||
else litellm.sse_keepalive_ping_interval_seconds
|
||||
)
|
||||
try:
|
||||
value: Final = float(raw) if isinstance(raw, (int, float, str)) else 0.0
|
||||
except ValueError:
|
||||
|
|
@ -7977,18 +8054,19 @@ async def async_data_generator(
|
|||
|
||||
# A stream can start on a deployment with keepalive off and fall back
|
||||
# mid-stream to one that enables it: only skip wrapping altogether when
|
||||
# there's no router to ever fall back through in the first place (in
|
||||
# which case _resolve_keepalive_seconds can never return non-zero for
|
||||
# any chunk of this stream), not merely because the first chunk's
|
||||
# deployment happens to start with it off.
|
||||
# there's no router to ever fall back through AND the resolved interval
|
||||
# (including the global sse_keepalive_ping_interval_seconds fallback)
|
||||
# starts disabled, not merely because the first chunk's deployment
|
||||
# happens to start with it off.
|
||||
resolve_keepalive_seconds: Final = _make_keepalive_resolver(request_data)
|
||||
initial_keepalive_seconds: Final = resolve_keepalive_seconds(response)
|
||||
stream_source: Final = (
|
||||
_iter_with_keepalive(
|
||||
stream_iterator.__aiter__(),
|
||||
resolve_keepalive_seconds,
|
||||
resolve_keepalive_seconds(response),
|
||||
initial_keepalive_seconds,
|
||||
)
|
||||
if llm_router is not None
|
||||
if llm_router is not None or initial_keepalive_seconds > 0
|
||||
else stream_iterator
|
||||
)
|
||||
|
||||
|
|
@ -8671,14 +8749,14 @@ class ProxyStartupEvent:
|
|||
if general_settings.get("disable_spend_logs", False) is False:
|
||||
from litellm.proxy.utils import _monitor_spend_logs_queue
|
||||
|
||||
# Start background task to monitor spend logs queue size
|
||||
asyncio.create_task(
|
||||
monitor_task: Final = asyncio.create_task(
|
||||
_monitor_spend_logs_queue(
|
||||
prisma_client=prisma_client,
|
||||
db_writer_client=db_writer_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
)
|
||||
prisma_client.spend_logs_queue_monitor_task = monitor_task # rebind-ok: the client owns its monitor handle
|
||||
|
||||
### ADD NEW MODELS ###
|
||||
store_model_in_db = get_secret_bool("STORE_MODEL_IN_DB", store_model_in_db) or store_model_in_db
|
||||
|
|
@ -8931,6 +9009,14 @@ class ProxyStartupEvent:
|
|||
# Do NOT reset job times to "now" as this can trigger the memory leak
|
||||
# The misfire_grace_time and coalesce settings will handle any missed runs properly
|
||||
|
||||
# Every job above anchors on this process's start instant, so without a phase offset
|
||||
# they all fire together, on every replica the rollout brought up at the same time
|
||||
attach_job_timing_logger(scheduler)
|
||||
apply_scheduled_job_stagger(
|
||||
scheduler=scheduler,
|
||||
settings=parse_stagger_settings(general_settings),
|
||||
)
|
||||
|
||||
# Start the scheduler immediately without processing backlogs
|
||||
scheduler.start(paused=False)
|
||||
verbose_proxy_logger.info(
|
||||
|
|
@ -11858,6 +11944,8 @@ def _add_team_models_to_all_models(
|
|||
Add team models to all models
|
||||
"""
|
||||
team_models: Final[dict[str, set[str]]] = {}
|
||||
proxy_model_list: Final = llm_router.get_model_names()
|
||||
model_access_groups: Final = llm_router.get_model_access_groups()
|
||||
|
||||
for team_object in team_db_objects_typed:
|
||||
if (
|
||||
|
|
@ -11879,7 +11967,12 @@ def _add_team_models_to_all_models(
|
|||
if can_add_model:
|
||||
team_models.setdefault(model_id, set()).add(team_object.team_id)
|
||||
else:
|
||||
for model_name in team_object.models:
|
||||
resolved_model_names = get_team_models(
|
||||
team_models=team_object.models,
|
||||
proxy_model_list=proxy_model_list,
|
||||
model_access_groups=model_access_groups,
|
||||
)
|
||||
for model_name in resolved_model_names:
|
||||
_models = llm_router.get_model_list(model_name=model_name, team_id=team_object.team_id)
|
||||
if _models is not None:
|
||||
for model in _models:
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ def _parse_cursor_model_variant(model: str) -> _CursorModelVariant:
|
|||
def _router_can_serve(model: str, llm_router: "Router | None") -> bool:
|
||||
if llm_router is None:
|
||||
return False
|
||||
if model in llm_router.model_names or model in llm_router.model_group_alias:
|
||||
if llm_router.is_recognized_model(model):
|
||||
return True
|
||||
if model in llm_router.team_public_model_names:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -587,16 +587,10 @@ async def route_request(
|
|||
return getattr(llm_router, f"{route_type}")(**data)
|
||||
|
||||
elif (
|
||||
(
|
||||
is_proxy_admin_without_team
|
||||
and data["model"] not in router_model_names
|
||||
and data["model"] in llm_router.team_public_model_names
|
||||
)
|
||||
or data["model"] in router_model_names
|
||||
or llm_router.has_model_id(data["model"])
|
||||
or llm_router.model_group_alias is not None
|
||||
and data["model"] in llm_router.model_group_alias
|
||||
):
|
||||
is_proxy_admin_without_team
|
||||
and data["model"] not in router_model_names
|
||||
and data["model"] in llm_router.team_public_model_names
|
||||
) or llm_router.is_recognized_model(data["model"]):
|
||||
return getattr(llm_router, f"{route_type}")(**data)
|
||||
|
||||
elif data["model"] not in router_model_names:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import contextlib
|
||||
import copy
|
||||
import hashlib
|
||||
import inspect
|
||||
|
|
@ -3006,6 +3007,7 @@ async def prefetch_config_params(prisma_client: "PrismaClient | None", param_nam
|
|||
class PrismaClient:
|
||||
spend_log_transactions: list = []
|
||||
_spend_log_transactions_lock = asyncio.Lock()
|
||||
spend_logs_queue_monitor_task: "asyncio.Task[None] | None" = None
|
||||
tool_usage_transactions: list["ToolUsageTransaction"] = []
|
||||
_tool_usage_transactions_lock = asyncio.Lock()
|
||||
autorouter_turn_transactions: ClassVar[
|
||||
|
|
@ -5722,13 +5724,22 @@ async def update_spend_logs_job(
|
|||
logs_to_process: Final = prisma_client.spend_log_transactions[:MAX_LOGS_PER_INTERVAL]
|
||||
prisma_client.spend_log_transactions = prisma_client.spend_log_transactions[len(logs_to_process) :]
|
||||
|
||||
await ProxyUpdateSpend.update_spend_logs(
|
||||
n_retry_times=n_retry_times,
|
||||
prisma_client=prisma_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
db_writer_client=db_writer_client,
|
||||
logs_to_process=logs_to_process,
|
||||
)
|
||||
try:
|
||||
await ProxyUpdateSpend.update_spend_logs(
|
||||
n_retry_times=n_retry_times,
|
||||
prisma_client=prisma_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
db_writer_client=db_writer_client,
|
||||
logs_to_process=logs_to_process,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
async with prisma_client._spend_log_transactions_lock:
|
||||
prisma_client.spend_log_transactions[:0] = logs_to_process
|
||||
verbose_proxy_logger.warning(
|
||||
"Spend tracking - spend log write cancelled, requeued %d rows for the next flush",
|
||||
len(logs_to_process),
|
||||
)
|
||||
raise
|
||||
|
||||
# Guardrail/policy usage tracking (same batch, outside spend-logs update)
|
||||
try:
|
||||
|
|
@ -5787,6 +5798,39 @@ async def update_spend_logs_job(
|
|||
)
|
||||
|
||||
|
||||
MAX_SPEND_LOG_DRAIN_ITERATIONS: Final = 20
|
||||
|
||||
|
||||
async def drain_spend_logs_queue(
|
||||
prisma_client: PrismaClient,
|
||||
db_writer_client: "AsyncHTTPHandler | None",
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> None:
|
||||
monitor_task: Final = prisma_client.spend_logs_queue_monitor_task
|
||||
if monitor_task is not None:
|
||||
monitor_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await monitor_task
|
||||
prisma_client.spend_logs_queue_monitor_task = None # rebind-ok: the client owns its monitor handle
|
||||
|
||||
for _ in range(MAX_SPEND_LOG_DRAIN_ITERATIONS):
|
||||
if await _total_queued_spend_transactions(prisma_client) == 0:
|
||||
return
|
||||
await update_spend_logs_job(
|
||||
prisma_client=prisma_client,
|
||||
db_writer_client=db_writer_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
remaining: Final = await _total_queued_spend_transactions(prisma_client)
|
||||
if remaining > 0:
|
||||
spend_log_error(
|
||||
"Spend tracking - %d spend log rows still queued after %d drain passes",
|
||||
remaining,
|
||||
MAX_SPEND_LOG_DRAIN_ITERATIONS,
|
||||
)
|
||||
|
||||
|
||||
async def _monitor_spend_logs_queue(
|
||||
prisma_client: PrismaClient,
|
||||
db_writer_client: AsyncHTTPHandler | None,
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ class _CustomToolFormat(BaseModel):
|
|||
_ALLOWED_CALLERS_ADAPTER: Final = TypeAdapter(list[str] | None)
|
||||
|
||||
|
||||
def _validated_allowed_callers(value: object) -> list[str] | None:
|
||||
def validated_allowed_callers(value: object) -> list[str] | None:
|
||||
try:
|
||||
return _ALLOWED_CALLERS_ADAPTER.validate_python(value, strict=True)
|
||||
except ValidationError as exc:
|
||||
|
|
@ -143,7 +143,7 @@ def convert_custom_tool_to_function_tool(tool: Mapping[str, object]) -> ChatComp
|
|||
name: Final = raw_name if isinstance(raw_name, str) else ""
|
||||
raw_description: Final = tool.get("description")
|
||||
description = (raw_description if isinstance(raw_description, str) else "") + _grammar_suffix(tool.get("format"))
|
||||
allowed_callers: Final = _validated_allowed_callers(tool.get("allowed_callers"))
|
||||
allowed_callers: Final = validated_allowed_callers(tool.get("allowed_callers"))
|
||||
function_chunk: Final = ChatCompletionToolParamFunctionChunk(
|
||||
name=name,
|
||||
description=description,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
self.sent_output_item_done_event: bool = False
|
||||
self.sent_annotation_events: bool = False
|
||||
self.litellm_model_response: ModelResponse | TextCompletionResponse | None = None
|
||||
self.completed_response: Any = None
|
||||
self.final_text: str = ""
|
||||
self._cached_item_id: str | None = None
|
||||
self._cached_response_id: str | None = None
|
||||
|
|
@ -105,6 +106,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
self._accumulated_reasoning_content_parts: list[str] = []
|
||||
self._accumulated_provider_specific_fields: dict[str, Any] = {}
|
||||
self._custom_tool_names: set[str] = extract_custom_tool_names(self.responses_api_request.get("tools"))
|
||||
self._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(
|
||||
self.responses_api_request.get("tools")
|
||||
)
|
||||
|
||||
def _get_or_assign_tool_output_index(self, call_id: str) -> int:
|
||||
existing: Final = self._tool_output_index_by_call_id.get(call_id)
|
||||
|
|
@ -124,6 +128,13 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _responses_namespace_tool_call_fields(self, fn_name: str) -> tuple[str, str | None]:
|
||||
mapped: Final = self._namespace_tool_names.get(fn_name)
|
||||
if mapped:
|
||||
namespace, tool_name = mapped
|
||||
return tool_name, namespace
|
||||
return fn_name, None
|
||||
|
||||
def _is_reasoning_end(self, chunk):
|
||||
delta: Final = chunk.choices[0].delta
|
||||
|
||||
|
|
@ -182,13 +193,17 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
else:
|
||||
fn_name = str(getattr(fn, "name", "") or "")
|
||||
fn_args_delta = str(getattr(fn, "arguments", "") or "")
|
||||
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
|
||||
|
||||
output_index = self._get_or_assign_tool_output_index(call_id)
|
||||
|
||||
if call_id not in self._tool_args_by_call_id:
|
||||
self._tool_args_by_call_id[call_id] = ""
|
||||
self._sequence_number += 1
|
||||
item_kwargs = build_tool_call_item_kwargs(call_id, fn_name, "", "in_progress", self._custom_tool_names)
|
||||
names = self._custom_tool_names
|
||||
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names)
|
||||
if tool_namespace:
|
||||
item_kwargs["namespace"] = tool_namespace
|
||||
event = OutputItemAddedEvent(
|
||||
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
|
||||
output_index=output_index,
|
||||
|
|
@ -249,6 +264,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
else:
|
||||
fn_name = str(getattr(fn, "name", "") or "")
|
||||
fn_args = str(getattr(fn, "arguments", "") or "")
|
||||
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
|
||||
|
||||
# Track if this is a new tool call that wasn't streamed
|
||||
is_new_tool_call = call_id not in self._tool_args_by_call_id
|
||||
|
|
@ -257,7 +273,10 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
if is_new_tool_call:
|
||||
self._tool_args_by_call_id[call_id] = ""
|
||||
self._sequence_number += 1
|
||||
item_kwargs = build_tool_call_item_kwargs(call_id, fn_name, "", "in_progress", self._custom_tool_names)
|
||||
names = self._custom_tool_names
|
||||
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names)
|
||||
if tool_namespace:
|
||||
item_kwargs["namespace"] = tool_namespace
|
||||
event = OutputItemAddedEvent(
|
||||
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
|
||||
output_index=output_index,
|
||||
|
|
@ -299,9 +318,10 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
self._pending_tool_events.append(done_event)
|
||||
|
||||
self._sequence_number += 1
|
||||
item_kwargs = build_tool_call_item_kwargs(
|
||||
call_id, fn_name, final_args, "completed", self._custom_tool_names
|
||||
)
|
||||
names = self._custom_tool_names
|
||||
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, final_args, "completed", names)
|
||||
if tool_namespace:
|
||||
item_kwargs["namespace"] = tool_namespace
|
||||
item_done_event = OutputItemDoneEvent(
|
||||
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE,
|
||||
output_index=output_index,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,17 @@ Handles transforming from Responses API -> LiteLLM completion (Chat Completion
|
|||
import json
|
||||
import re
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, runtime_checkable
|
||||
from types import MappingProxyType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Final,
|
||||
Literal,
|
||||
Protocol,
|
||||
TypeAlias,
|
||||
cast,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
from openai.types.chat.chat_completion_named_tool_choice_param import (
|
||||
ChatCompletionNamedToolChoiceParam,
|
||||
|
|
@ -38,9 +48,11 @@ from litellm.types.llms.openai import (
|
|||
ChatCompletionToolCallFunctionChunk,
|
||||
ChatCompletionToolMessage,
|
||||
ChatCompletionToolParam,
|
||||
ChatCompletionToolParamFunctionChunk,
|
||||
ChatCompletionUserMessage,
|
||||
GenericChatCompletionMessage,
|
||||
InputTokensDetails,
|
||||
OpenAIChatCompletionTextObject,
|
||||
OpenAIMcpServerTool,
|
||||
OpenAIWebSearchOptions,
|
||||
OpenAIWebSearchUserLocation,
|
||||
|
|
@ -77,8 +89,13 @@ from .custom_tools import (
|
|||
extract_custom_tool_names,
|
||||
is_custom_tool_call,
|
||||
unwrap_custom_tool_arguments,
|
||||
validated_allowed_callers,
|
||||
)
|
||||
|
||||
NamespaceNameMap: TypeAlias = Mapping[str, tuple[str, str]]
|
||||
NamespaceTool: TypeAlias = Mapping[str, object]
|
||||
ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai.types.responses.response_apply_patch_tool_call import (
|
||||
ResponseApplyPatchToolCall,
|
||||
|
|
@ -528,9 +545,52 @@ class LiteLLMCompletionResponsesConfig:
|
|||
messages.extend(deduped_in_place)
|
||||
continue
|
||||
|
||||
merged_assistant = LiteLLMCompletionResponsesConfig._merged_trailing_assistant_message(
|
||||
messages=messages,
|
||||
chat_completion_messages=chat_completion_messages,
|
||||
)
|
||||
if merged_assistant is not None:
|
||||
messages[-1] = merged_assistant
|
||||
continue
|
||||
|
||||
messages.extend(chat_completion_messages)
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _merged_trailing_assistant_message(
|
||||
messages: Sequence[
|
||||
AllMessageValues
|
||||
| GenericChatCompletionMessage
|
||||
| ChatCompletionMessageToolCall
|
||||
| ChatCompletionResponseMessage
|
||||
],
|
||||
chat_completion_messages: Sequence[
|
||||
AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage
|
||||
],
|
||||
) -> ChatCompletionResponseMessage | None:
|
||||
"""Fold an assistant content message into a directly preceding assistant
|
||||
tool_calls message. Providers like DeepSeek and Anthropic require tool
|
||||
results immediately after the tool_calls message, so an assistant message
|
||||
between them is rejected."""
|
||||
if not messages or len(chat_completion_messages) != 1:
|
||||
return None
|
||||
last_message = messages[-1]
|
||||
new_message = chat_completion_messages[0]
|
||||
if not isinstance(last_message, dict):
|
||||
return None
|
||||
if last_message.get("role") != "assistant" or new_message.get("role") != "assistant":
|
||||
return None
|
||||
if not last_message.get("tool_calls") or last_message.get("content") or new_message.get("tool_calls"):
|
||||
return None
|
||||
new_content = new_message.get("content")
|
||||
if new_content is None:
|
||||
return None
|
||||
merged: Final = { # mutable-ok: json.dumps rejects MappingProxyType in outbound chat messages
|
||||
**last_message,
|
||||
"content": new_content,
|
||||
}
|
||||
return cast(ChatCompletionResponseMessage, merged) # cast-ok: TypedDict spread widens to dict[str, object]
|
||||
|
||||
@staticmethod
|
||||
def _deduplicate_tool_call_output_messages(
|
||||
tool_call_output_messages: list[
|
||||
|
|
@ -1163,11 +1223,14 @@ class LiteLLMCompletionResponsesConfig:
|
|||
if not raw_arguments and function_call.get("type") == "custom_tool_call":
|
||||
raw_input: Final = function_call.get("input") or ""
|
||||
raw_arguments = json.dumps({"content": raw_input}) if raw_input else ""
|
||||
raw_name: Final = function_call.get("name") or ""
|
||||
namespace: Final = function_call.get("namespace") or ""
|
||||
qualify: Final = bool(namespace) and function_call.get("type") != "custom_tool_call"
|
||||
tool_call: Final = ChatCompletionToolCallChunk(
|
||||
id=function_call.get("call_id") or function_call.get("id") or "",
|
||||
type="function",
|
||||
function=ChatCompletionToolCallFunctionChunk(
|
||||
name=function_call.get("name") or "",
|
||||
name=f"{namespace}__{raw_name}" if qualify else raw_name,
|
||||
arguments=str(raw_arguments or ""),
|
||||
),
|
||||
index=0,
|
||||
|
|
@ -1260,6 +1323,12 @@ class LiteLLMCompletionResponsesConfig:
|
|||
if "cache_control" in item:
|
||||
image_block["cache_control"] = item["cache_control"]
|
||||
content_list.append(image_block)
|
||||
elif item.get("type") == "encrypted_content":
|
||||
encrypted_content = item.get("encrypted_content")
|
||||
if encrypted_content is not None:
|
||||
content_list.append(
|
||||
OpenAIChatCompletionTextObject(type="text", text=str(encrypted_content))
|
||||
)
|
||||
else:
|
||||
# Skip text blocks with None text to avoid downstream errors
|
||||
text_value = item.get("text")
|
||||
|
|
@ -1320,6 +1389,92 @@ class LiteLLMCompletionResponsesConfig:
|
|||
"""
|
||||
return ChatCompletionSystemMessage(role="system", content=instructions or "")
|
||||
|
||||
@staticmethod
|
||||
def _build_ns_chat_tool(
|
||||
namespace: str,
|
||||
namespace_description: str,
|
||||
namespace_tool: NamespaceTool,
|
||||
nested: bool,
|
||||
) -> ChatCompletionToolParam | None:
|
||||
if nested and namespace_tool.get("type") != "function":
|
||||
return None
|
||||
|
||||
raw_parameters: Final = namespace_tool.get("parameters")
|
||||
parameters: Final = (
|
||||
MappingProxyType(raw_parameters) if isinstance(raw_parameters, Mapping) else MappingProxyType({})
|
||||
)
|
||||
normalized_parameters: Final = (
|
||||
parameters if parameters and "type" in parameters else MappingProxyType({**parameters, "type": "object"})
|
||||
)
|
||||
tool_name: Final = str(namespace_tool.get("name") or "")
|
||||
raw_description: Final = str(namespace_tool.get("description") or "")
|
||||
description: Final = (
|
||||
f"{namespace_description}\n\n{raw_description}"
|
||||
if nested and namespace_description and raw_description
|
||||
else namespace_description
|
||||
if nested and namespace_description
|
||||
else raw_description
|
||||
)
|
||||
chat_tool_name: Final = f"{namespace}__{tool_name}" if nested else tool_name
|
||||
function: Final = ChatCompletionToolParamFunctionChunk(
|
||||
name=chat_tool_name,
|
||||
description=description,
|
||||
parameters=dict( # mutable-ok: json.dumps rejects MappingProxyType in the outbound payload
|
||||
normalized_parameters
|
||||
),
|
||||
strict=bool(namespace_tool.get("strict", False)),
|
||||
)
|
||||
allowed_callers: Final = validated_allowed_callers(namespace_tool.get("allowed_callers"))
|
||||
if allowed_callers is None:
|
||||
return ChatCompletionToolParam(type="function", function=function)
|
||||
return ChatCompletionToolParam(type="function", function=function, allowed_callers=allowed_callers)
|
||||
|
||||
@staticmethod
|
||||
def _namespace_chat_tools(tool: NamespaceTool) -> tuple[ChatCompletionToolParam, ...]:
|
||||
namespace: Final = str(tool.get("name") or "")
|
||||
namespace_description: Final = str(tool.get("description") or "")
|
||||
namespace_tools: Final = tool.get("tools")
|
||||
if isinstance(namespace_tools, Sequence) and not isinstance(namespace_tools, (str, bytes)):
|
||||
return tuple(
|
||||
chat_tool
|
||||
for raw_tool in namespace_tools
|
||||
if isinstance(raw_tool, Mapping)
|
||||
if (
|
||||
chat_tool := LiteLLMCompletionResponsesConfig._build_ns_chat_tool(
|
||||
namespace,
|
||||
namespace_description,
|
||||
raw_tool,
|
||||
True,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
flat_tool: Final = LiteLLMCompletionResponsesConfig._build_ns_chat_tool(
|
||||
namespace, namespace_description, tool, False
|
||||
)
|
||||
return (flat_tool,) if flat_tool is not None else ()
|
||||
|
||||
@staticmethod
|
||||
def _validate_namespace_name_collisions(tools: ResponseTools) -> None:
|
||||
top_level_function_names: Final = frozenset(
|
||||
str(tool.get("name") or "") for tool in tools or () if tool.get("type") == "function"
|
||||
)
|
||||
flattened_namespace_names: Final = frozenset(
|
||||
f"{(tool.get('name') or '')!s}__{(namespace_tool.get('name') or '')!s}"
|
||||
for tool in tools or ()
|
||||
if tool.get("type") == "namespace"
|
||||
for namespace_tools in (tool.get("tools"),)
|
||||
if isinstance(namespace_tools, Sequence) and not isinstance(namespace_tools, (str, bytes))
|
||||
for namespace_tool in namespace_tools
|
||||
if isinstance(namespace_tool, Mapping) and namespace_tool.get("type") == "function"
|
||||
)
|
||||
conflicting_tool_names: Final = top_level_function_names & flattened_namespace_names
|
||||
if conflicting_tool_names:
|
||||
raise ValueError(
|
||||
"Top-level function names conflict with flattened namespace tools: "
|
||||
+ ", ".join(sorted(conflicting_tool_names))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def transform_responses_api_tools_to_chat_completion_tools(
|
||||
tools: list[FunctionToolParam | OpenAIMcpServerTool] | None,
|
||||
|
|
@ -1332,6 +1487,7 @@ class LiteLLMCompletionResponsesConfig:
|
|||
"""
|
||||
if tools is None:
|
||||
return [], None
|
||||
LiteLLMCompletionResponsesConfig._validate_namespace_name_collisions(tools)
|
||||
chat_completion_tools: Final[list[ChatCompletionToolParam | OpenAIMcpServerTool]] = []
|
||||
web_search_options: OpenAIWebSearchOptions | None = None
|
||||
for tool in tools:
|
||||
|
|
@ -1373,13 +1529,15 @@ class LiteLLMCompletionResponsesConfig:
|
|||
if tool.get("input_examples"):
|
||||
chat_completion_tool["input_examples"] = tool.get("input_examples")
|
||||
chat_completion_tools.append(cast(ChatCompletionToolParam, chat_completion_tool))
|
||||
elif tool.get("type") == "namespace":
|
||||
chat_completion_tools.extend(LiteLLMCompletionResponsesConfig._namespace_chat_tools(tool))
|
||||
elif tool.get("type") == "custom":
|
||||
converted = convert_custom_tool_to_function_tool(tool)
|
||||
if converted is not None:
|
||||
chat_completion_tools.append(converted)
|
||||
else:
|
||||
_tool_type = tool.get("type")
|
||||
if _tool_type in ("computer_use", "image_generation", "namespace", "shell"):
|
||||
if _tool_type in ("computer_use", "image_generation", "shell"):
|
||||
# Drop unsupported Responses-API-only tool types that have no
|
||||
# Chat Completions equivalent. Passing them through verbatim
|
||||
# causes providers to reject the request with "'function' is a
|
||||
|
|
@ -1435,6 +1593,44 @@ class LiteLLMCompletionResponsesConfig:
|
|||
result.append(dict(tool))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def namespace_tool_name_map(tools: ResponseTools) -> NamespaceNameMap:
|
||||
namespace_entries: Final = tuple(
|
||||
(str(tool.get("name") or ""), str(namespace_tool.get("name") or ""))
|
||||
for tool in tools or ()
|
||||
if tool.get("type") == "namespace"
|
||||
for namespace_tools in (tool.get("tools"),)
|
||||
if isinstance(namespace_tools, Sequence) and not isinstance(namespace_tools, (str, bytes))
|
||||
for namespace_tool in namespace_tools
|
||||
if isinstance(namespace_tool, Mapping) and namespace_tool.get("type") == "function"
|
||||
)
|
||||
top_level_function_names: Final = frozenset(
|
||||
str(tool.get("name") or "") for tool in tools or () if tool.get("type") == "function"
|
||||
)
|
||||
unqualified_counts: Final = MappingProxyType(
|
||||
{
|
||||
tool_name: sum(1 for _, candidate_name in namespace_entries if candidate_name == tool_name)
|
||||
for tool_name in frozenset(tool_name for _, tool_name in namespace_entries)
|
||||
}
|
||||
)
|
||||
unambiguous_entries: Final = tuple(
|
||||
(tool_name, (namespace, tool_name))
|
||||
for namespace, tool_name in namespace_entries
|
||||
if tool_name not in top_level_function_names and unqualified_counts[tool_name] == 1
|
||||
)
|
||||
qualified_entries: Final = tuple(
|
||||
(f"{namespace}__{tool_name}", (namespace, tool_name)) for namespace, tool_name in namespace_entries
|
||||
)
|
||||
return MappingProxyType(dict(qualified_entries + unambiguous_entries))
|
||||
|
||||
@staticmethod
|
||||
def _restore_namespace_tool_name(tool_name: str, names: NamespaceNameMap) -> tuple[str, str | None]:
|
||||
mapped = names.get(tool_name)
|
||||
if mapped is None:
|
||||
return tool_name, None
|
||||
namespace, restored_tool_name = mapped
|
||||
return restored_tool_name, namespace
|
||||
|
||||
@staticmethod
|
||||
def transform_chat_completion_tools_to_responses_tools(
|
||||
chat_completion_response: ModelResponse,
|
||||
|
|
@ -1458,10 +1654,9 @@ class LiteLLMCompletionResponsesConfig:
|
|||
value=tool_call,
|
||||
)
|
||||
|
||||
# Extract custom tool names from the original request
|
||||
custom_tool_names: set[str] = set()
|
||||
if responses_api_request and "tools" in responses_api_request:
|
||||
custom_tool_names = extract_custom_tool_names(responses_api_request["tools"])
|
||||
request_tools: Final = responses_api_request.get("tools") if responses_api_request is not None else None
|
||||
custom_tool_names: Final = extract_custom_tool_names(request_tools)
|
||||
namespace_tool_names: Final = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(request_tools)
|
||||
|
||||
responses_tools: Final[list[ResponseFunctionToolCall | CustomToolCallOutputItem]] = []
|
||||
for tool in all_chat_completion_tools:
|
||||
|
|
@ -1486,6 +1681,9 @@ class LiteLLMCompletionResponsesConfig:
|
|||
responses_tools.append(custom_item)
|
||||
else:
|
||||
# Build regular function_call output item
|
||||
restore_name = LiteLLMCompletionResponsesConfig._restore_namespace_tool_name
|
||||
tool_name, namespace = restore_name(tool_name, namespace_tool_names)
|
||||
|
||||
provider_specific_fields: dict | None = None
|
||||
if hasattr(tool, "provider_specific_fields") and getattr(tool, "provider_specific_fields", None):
|
||||
provider_specific_fields = getattr(tool, "provider_specific_fields")
|
||||
|
|
@ -1510,6 +1708,8 @@ class LiteLLMCompletionResponsesConfig:
|
|||
type="function_call",
|
||||
status=function_definition.get("status") or "completed",
|
||||
)
|
||||
if namespace:
|
||||
output_tool_call.namespace = namespace
|
||||
|
||||
# Pass through provider_specific_fields as-is if present
|
||||
if provider_specific_fields:
|
||||
|
|
|
|||
|
|
@ -1033,13 +1033,18 @@ class ResponseAPILoggingUtils:
|
|||
|
||||
@staticmethod
|
||||
def _transform_response_api_usage_to_chat_usage(
|
||||
usage_input: dict | ResponseAPIUsage | None,
|
||||
usage_input: Mapping[str, object] | ResponseAPIUsage | Usage | None,
|
||||
) -> Usage:
|
||||
"""
|
||||
Transforms ResponseAPIUsage or ImageUsage to a Usage object.
|
||||
|
||||
Both have the same spec with input_tokens, output_tokens, and
|
||||
input_tokens_details (text_tokens, image_tokens).
|
||||
|
||||
Usage inputs are returned as-is so re-running this helper never drops
|
||||
fields. Non-standard provider fields (e.g. xAI's
|
||||
server_side_tool_usage_details) are carried onto the returned Usage so
|
||||
provider cost calculators can read them after normalization.
|
||||
"""
|
||||
if usage_input is None:
|
||||
return Usage(
|
||||
|
|
@ -1047,6 +1052,10 @@ class ResponseAPILoggingUtils:
|
|||
completion_tokens=0,
|
||||
total_tokens=0,
|
||||
)
|
||||
if isinstance(usage_input, Usage):
|
||||
return usage_input
|
||||
if isinstance(usage_input, dict) and not ResponseAPILoggingUtils._is_response_api_usage(usage_input):
|
||||
return Usage(**usage_input)
|
||||
response_api_usage: ResponseAPIUsage
|
||||
if isinstance(usage_input, dict):
|
||||
usage_input = dict(usage_input) # shallow copy; avoid mutating caller
|
||||
|
|
@ -1055,13 +1064,11 @@ class ResponseAPILoggingUtils:
|
|||
usage_input["input_tokens_details"] = usage_input["input_token_details"]
|
||||
if usage_input.get("output_tokens_details") is None and "output_token_details" in usage_input:
|
||||
usage_input["output_tokens_details"] = usage_input["output_token_details"]
|
||||
total_tokens = usage_input.get("total_tokens")
|
||||
if total_tokens is None:
|
||||
if usage_input.get("total_tokens") is None:
|
||||
input_tokens: Final = usage_input.get("input_tokens")
|
||||
output_tokens: Final = usage_input.get("output_tokens")
|
||||
if input_tokens is not None and output_tokens is not None:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
usage_input["total_tokens"] = total_tokens
|
||||
if isinstance(input_tokens, int) and isinstance(output_tokens, int):
|
||||
usage_input["total_tokens"] = input_tokens + output_tokens
|
||||
response_api_usage = ResponseAPIUsage(**usage_input)
|
||||
else:
|
||||
response_api_usage = usage_input
|
||||
|
|
@ -1089,12 +1096,27 @@ class ResponseAPILoggingUtils:
|
|||
audio_tokens=getattr(output_tokens_details, "audio_tokens", None),
|
||||
)
|
||||
|
||||
extra_usage_fields: Final = {
|
||||
key: value
|
||||
for key, value in (response_api_usage.model_extra or {}).items()
|
||||
if key
|
||||
not in (
|
||||
"input_token_details",
|
||||
"output_token_details",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
"prompt_tokens_details",
|
||||
"completion_tokens_details",
|
||||
)
|
||||
}
|
||||
chat_usage: Final = Usage(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
prompt_tokens_details=prompt_tokens_details,
|
||||
completion_tokens_details=completion_tokens_details,
|
||||
**extra_usage_fields,
|
||||
)
|
||||
|
||||
# Preserve cost attribute if it exists on ResponseAPIUsage
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from litellm.caching.caching import (
|
|||
RedisClusterCache,
|
||||
)
|
||||
from litellm.constants import (
|
||||
CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
DEFAULT_AUTO_ROUTER_MAX_INPUT_CHARS,
|
||||
DEFAULT_HEALTH_CHECK_INTERVAL,
|
||||
DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER,
|
||||
|
|
@ -54,6 +55,7 @@ from litellm.litellm_core_utils.asyncify import run_async_function
|
|||
from litellm.litellm_core_utils.core_helpers import (
|
||||
_get_parent_otel_span_from_kwargs,
|
||||
coerce_token_limit,
|
||||
get_litellm_metadata_from_kwargs,
|
||||
get_metadata_variable_name_from_kwargs,
|
||||
get_or_create_metadata_bucket,
|
||||
)
|
||||
|
|
@ -94,6 +96,7 @@ from litellm.router_utils.add_retry_fallback_headers import (
|
|||
response_in_flight_token_count,
|
||||
)
|
||||
from litellm.router_utils.auto_router_model_naming import (
|
||||
AUTO_ROUTER_MODEL_PREFIX,
|
||||
classify_strategy_router_model,
|
||||
)
|
||||
from litellm.router_utils.batch_utils import (
|
||||
|
|
@ -170,6 +173,7 @@ from litellm.types.router import (
|
|||
AlertingConfig,
|
||||
AllowedFailsPolicy,
|
||||
AssistantsTypedDict,
|
||||
ConsumedRequestTagsStamp,
|
||||
CredentialLiteLLMParams,
|
||||
CustomRoutingStrategyBase,
|
||||
Deployment,
|
||||
|
|
@ -315,6 +319,8 @@ def model_info_is_active_for_environment(model_info: Mapping[str, object] | None
|
|||
|
||||
_PreRoutingStrategyT = TypeVar("_PreRoutingStrategyT")
|
||||
|
||||
_ALIAS_PARAMS_NEVER_FORWARDED: Final = frozenset({"model", "api_base", "api_key", "api_version"})
|
||||
|
||||
|
||||
def _stream_chunks_have_generated_content(chunks: Sequence[ModelResponseStream]) -> bool:
|
||||
for chunk in chunks:
|
||||
|
|
@ -608,8 +614,10 @@ class Router:
|
|||
self.team_public_model_names: frozenset[str] = frozenset()
|
||||
|
||||
# Initialize cache attributes that ``_invalidate_model_group_info_cache``
|
||||
# touches *before* the first ``set_model_list`` below (which calls
|
||||
# that invalidation as part of building the model index).
|
||||
# and ``_invalidate_access_groups_cache`` touch *before* the first
|
||||
# ``set_model_list`` below (which calls those invalidations as part of
|
||||
# building the model index) and before ``_init_routing_groups(None)``
|
||||
# (which calls them on every group rebuild).
|
||||
self._access_groups_cache: dict[str, list[str]] | None = None
|
||||
# Per-router cache for the proxy auth-layer "is this model explicitly
|
||||
# zero-cost?" check. Lives on the router so it is invalidated alongside
|
||||
|
|
@ -617,6 +625,8 @@ class Router:
|
|||
# ``id()``-reuse risk after GC). See
|
||||
# ``litellm.proxy.auth.auth_checks._is_model_cost_zero``.
|
||||
self._zero_cost_cache: dict[str, bool] = {}
|
||||
self._routing_group_rows: tuple[DeploymentTypedDict, ...] | None = None
|
||||
self._init_routing_groups(None)
|
||||
|
||||
self.deployment_affinity_ttl_seconds = deployment_affinity_ttl_seconds
|
||||
self.model_group_affinity_config = model_group_affinity_config
|
||||
|
|
@ -1039,6 +1049,8 @@ class Router:
|
|||
self._routing_groups: dict[str, RoutingGroup] = {}
|
||||
self._model_to_group: dict[str, str] = {}
|
||||
self._group_selectors: dict[str, dict[str, RouterStrategySelector]] = {}
|
||||
self._invalidate_model_group_info_cache()
|
||||
self._invalidate_access_groups_cache()
|
||||
|
||||
if not groups_input:
|
||||
return
|
||||
|
|
@ -1053,6 +1065,12 @@ class Router:
|
|||
raise ValueError("routing_groups: group_name must be non-empty.")
|
||||
if group.group_name == "default":
|
||||
raise ValueError("routing_groups: 'default' is reserved for the implicit fallback group.")
|
||||
if group.group_name in known_model_names or group.group_name in (self.model_group_alias or {}):
|
||||
verbose_router_logger.warning(
|
||||
"routing_groups: group_name '%s' is shadowed by an existing model_name or model_group_alias; "
|
||||
"the group's strategy still applies to its members, but the name is not callable until renamed.",
|
||||
group.group_name,
|
||||
)
|
||||
if group.group_name in seen_group_names:
|
||||
raise ValueError(
|
||||
f"routing_groups: group names must be unique, duplicate group_name '{group.group_name}'."
|
||||
|
|
@ -1089,6 +1107,82 @@ class Router:
|
|||
{strategy_value: group_selector} if group_selector is not None else {}
|
||||
)
|
||||
|
||||
def get_routing_group(self, model_name: str) -> RoutingGroup | None:
|
||||
"""
|
||||
The routing group callable as `model_name`, or None. A real deployment
|
||||
`model_name` added after init shadows a same-named group (mirroring
|
||||
`_try_early_resolve_deployments_for_model_not_in_names`, where concrete
|
||||
models win over indirection); config-time collisions are rejected by
|
||||
`_init_routing_groups`.
|
||||
"""
|
||||
if not self._routing_groups:
|
||||
return None
|
||||
group: Final = self._routing_groups.get(model_name)
|
||||
if (
|
||||
group is None
|
||||
or model_name in self.model_name_to_deployment_indices
|
||||
or model_name in (self.model_group_alias or {})
|
||||
):
|
||||
return None
|
||||
return group
|
||||
|
||||
def _get_routing_group_deployments(
|
||||
self, model: str, team_id: str | None = None
|
||||
) -> list[DeploymentTypedDict] | None: # mutable-ok: list matches _get_all_deployments' contract for callers
|
||||
"""
|
||||
The union of member deployments for a routing group called as `model`,
|
||||
or None when `model` is not a callable group. The requested name stays
|
||||
the group name so strategy selectors key their state by it.
|
||||
|
||||
`_common_checks_available_deployment` consults this BEFORE its
|
||||
early-resolve step so a wildcard `default_deployment` or pattern route
|
||||
cannot hijack a group call. Overall resolution precedence there:
|
||||
specific deployment > model id > model_group_alias > routing group >
|
||||
model_name > team/pattern/default fallbacks.
|
||||
"""
|
||||
if not self._routing_groups:
|
||||
return None
|
||||
routing_group: Final = self.get_routing_group(model)
|
||||
if routing_group is None:
|
||||
return None
|
||||
return [ # mutable-ok: matches _get_all_deployments' list contract expected by downstream filters
|
||||
deployment
|
||||
for member in routing_group.models
|
||||
for deployment in self._get_all_deployments(model_name=member, team_id=team_id)
|
||||
]
|
||||
|
||||
def is_recognized_model(self, model: str) -> bool:
|
||||
"""
|
||||
Whether `model` names something this router serves directly: a
|
||||
deployment model_name, a deployment id, a `model_group_alias`, or a
|
||||
callable routing group. Proxy request gates share this predicate so a
|
||||
new virtual-model kind cannot be forgotten at one of them; wildcard,
|
||||
default-deployment, and deployment-name fallbacks stay caller policy.
|
||||
"""
|
||||
return (
|
||||
model in self.model_names
|
||||
or self.has_model_id(model)
|
||||
or (self.model_group_alias is not None and model in self.model_group_alias)
|
||||
or self.get_routing_group(model) is not None
|
||||
)
|
||||
|
||||
def routing_group_has_alternatives(self, model_group: str | None) -> bool:
|
||||
"""
|
||||
True when `model_group` names a callable routing group whose member
|
||||
union spans more than one deployment. Cooldown handling passes the
|
||||
FAILING REQUEST's model group here: a 429 on a group call cools the
|
||||
member down so selection moves to the group's alternatives, while a
|
||||
direct call to a single-deployment member keeps the
|
||||
single-deployment-model-group cooldown exemption.
|
||||
"""
|
||||
if model_group is None:
|
||||
return False
|
||||
resolved: Final = self._get_model_from_alias(model=model_group) or model_group
|
||||
group: Final = self.get_routing_group(resolved)
|
||||
if group is None:
|
||||
return False
|
||||
return sum(len(self.model_name_to_deployment_indices.get(member) or ()) for member in group.models) > 1
|
||||
|
||||
_OVERRIDABLE_ROUTING_STRATEGIES: frozenset[str] = frozenset({"simple-shuffle", *_DEFAULT_SELECTOR_ATTR_BY_STRATEGY})
|
||||
|
||||
def _get_request_routing_strategy_override(self, request_kwargs: dict | None) -> str | None:
|
||||
|
|
@ -1149,8 +1243,10 @@ class Router:
|
|||
the most specific expression of caller intent.
|
||||
|
||||
Otherwise every model belongs to exactly one group: an explicit entry
|
||||
from `routing_groups`, or the implicit `"default"` group driven by the
|
||||
router's top-level `routing_strategy` / `routing_strategy_args`.
|
||||
from `routing_groups` (either because `model` IS a callable group name,
|
||||
or because it is a member of one), or the implicit `"default"` group
|
||||
driven by the router's top-level `routing_strategy` /
|
||||
`routing_strategy_args`.
|
||||
|
||||
`self.routing_strategy` may be either a string or a `RoutingStrategy`
|
||||
enum member (the constructor accepts both), so it is normalized to a
|
||||
|
|
@ -1162,7 +1258,7 @@ class Router:
|
|||
verbose_router_logger.debug("routing_group=request-override model=%s strategy=%s", model, override)
|
||||
return override, self._get_override_strategy_selector(override)
|
||||
|
||||
group_name: Final = self._model_to_group.get(model)
|
||||
group_name: Final = model if self.get_routing_group(model) is not None else self._model_to_group.get(model)
|
||||
if group_name is None:
|
||||
strategy = self._normalize_strategy(self.routing_strategy)
|
||||
attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(strategy or "")
|
||||
|
|
@ -7143,6 +7239,7 @@ class Router:
|
|||
original_exception=exception,
|
||||
deployment=deployment_id,
|
||||
time_to_cooldown=_time_to_cooldown,
|
||||
requested_model_group=(get_litellm_metadata_from_kwargs(kwargs) or {}).get("model_group"),
|
||||
) # setting deployment_id in cooldown deployments
|
||||
|
||||
return result
|
||||
|
|
@ -8326,6 +8423,7 @@ class Router:
|
|||
self.model_name_to_deployment_indices[model_name] = updated_indices
|
||||
else:
|
||||
del self.model_name_to_deployment_indices[model_name]
|
||||
self.model_names.discard(model_name)
|
||||
|
||||
# Update team_model_to_deployment_indices
|
||||
for key, indices in list(self.team_model_to_deployment_indices.items()):
|
||||
|
|
@ -8517,7 +8615,18 @@ class Router:
|
|||
Nothing is recorded for replay: a refresh walks the live routers instead,
|
||||
so a deleted, repointed or never-added deployment, and a discarded router,
|
||||
drop out of the rebuild on their own.
|
||||
|
||||
A strategy-router alias is never the deployment actually called or
|
||||
billed, so custom pricing configured on it must not become a cost-map
|
||||
price: an explicit zero would let ``_is_cost_explicitly_configured``
|
||||
treat the alias as a genuinely free model and waive budget checks for
|
||||
requests that route to (and bill as) a real deployment.
|
||||
"""
|
||||
if classify_strategy_router_model(model) is not None:
|
||||
model_info = { # mutable-ok: filtered copy of the caller's entry, handed straight to register_model
|
||||
k: v for k, v in model_info.items() if k not in CustomPricingLiteLLMParams.model_fields
|
||||
}
|
||||
|
||||
if model_id is not None:
|
||||
litellm.register_model(model_cost={model_id: model_info}, persist_across_reloads=False)
|
||||
|
||||
|
|
@ -9981,6 +10090,52 @@ class Router:
|
|||
|
||||
return returned_models
|
||||
|
||||
def get_model_list_from_routing_groups(self, model_name: str | None = None) -> Sequence[DeploymentTypedDict]:
|
||||
"""
|
||||
Callable routing groups materialized as model-list rows, mirroring
|
||||
`get_model_list_from_model_alias`: each member deployment is emitted
|
||||
under the group's name (via `_get_all_deployments`' `model_alias`
|
||||
rewrite), which is what surfaces groups in `get_model_names`,
|
||||
`/v1/models` discovery, `get_model_group_usage`, and the
|
||||
blocked/unhealthy hiding that all read `get_model_list`.
|
||||
"""
|
||||
if model_name is not None:
|
||||
group: Final = self.get_routing_group(model_name)
|
||||
return self._materialize_routing_group_rows((group,)) if group is not None else ()
|
||||
cached: Final = self._routing_group_rows
|
||||
if cached is not None:
|
||||
return cached
|
||||
rows: Final = self._materialize_routing_group_rows(
|
||||
tuple(
|
||||
callable_group
|
||||
for name in self._routing_groups
|
||||
if (callable_group := self.get_routing_group(name)) is not None
|
||||
)
|
||||
)
|
||||
self._routing_group_rows = rows
|
||||
return rows
|
||||
|
||||
def _materialize_routing_group_rows(self, groups: tuple[RoutingGroup, ...]) -> tuple[DeploymentTypedDict, ...]:
|
||||
return tuple(
|
||||
self._as_routing_group_row(deployment)
|
||||
for group in groups
|
||||
for member in group.models
|
||||
for deployment in self._get_all_deployments(model_name=member, model_alias=group.group_name)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_routing_group_row(deployment: DeploymentTypedDict) -> DeploymentTypedDict:
|
||||
"""
|
||||
A member deployment re-emitted under its group's name must not carry
|
||||
the member's `access_groups`: access groups grant member names, never
|
||||
the group, so inheriting them here would let a key holding a member's
|
||||
access group list and call the whole group.
|
||||
"""
|
||||
model_info: Final = { # mutable-ok: DeploymentTypedDict rows are plain dicts
|
||||
k: v for k, v in (deployment.get("model_info") or {}).items() if k != "access_groups"
|
||||
}
|
||||
return {**deployment, "model_info": model_info} # mutable-ok: DeploymentTypedDict rows are plain dicts
|
||||
|
||||
def get_model_list(
|
||||
self, model_name: str | None = None, team_id: str | None = None
|
||||
) -> list[DeploymentTypedDict] | None:
|
||||
|
|
@ -9997,6 +10152,7 @@ class Router:
|
|||
returned_models.extend(self._get_all_deployments(model_name=model_name, team_id=team_id))
|
||||
|
||||
returned_models.extend(self.get_model_list_from_model_alias(model_name=model_name))
|
||||
returned_models.extend(self.get_model_list_from_routing_groups(model_name=model_name))
|
||||
|
||||
if len(returned_models) == 0: # check if wildcard route
|
||||
potential_wildcard_models: Final = self.pattern_router.route(model_name) or []
|
||||
|
|
@ -10028,6 +10184,7 @@ class Router:
|
|||
"""
|
||||
self._cached_get_model_group_info.cache_clear()
|
||||
self._zero_cost_cache.clear()
|
||||
self._routing_group_rows = None
|
||||
|
||||
def _invalidate_access_groups_cache(self) -> None:
|
||||
"""Invalidate the cached access groups.
|
||||
|
|
@ -10558,6 +10715,14 @@ class Router:
|
|||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_strategy_marker_deployment(deployment: Mapping[str, object]) -> bool:
|
||||
litellm_params: Final = deployment.get("litellm_params")
|
||||
if not isinstance(litellm_params, Mapping):
|
||||
return False
|
||||
deployment_model: Final = litellm_params.get("model")
|
||||
return isinstance(deployment_model, str) and classify_strategy_router_model(deployment_model) is not None
|
||||
|
||||
def _common_checks_available_deployment(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -10598,17 +10763,23 @@ class Router:
|
|||
if _model_from_alias is not None:
|
||||
model = _model_from_alias
|
||||
|
||||
early: Final = self._try_early_resolve_deployments_for_model_not_in_names(
|
||||
model=model,
|
||||
request_team_id=request_team_id,
|
||||
include_team_models=_is_proxy_admin_request(request_kwargs),
|
||||
)
|
||||
if early is not None:
|
||||
return early
|
||||
_routing_group_deployments: Final = self._get_routing_group_deployments(model=model, team_id=request_team_id)
|
||||
if _routing_group_deployments is None:
|
||||
early: Final = self._try_early_resolve_deployments_for_model_not_in_names(
|
||||
model=model,
|
||||
request_team_id=request_team_id,
|
||||
include_team_models=_is_proxy_admin_request(request_kwargs),
|
||||
)
|
||||
if early is not None:
|
||||
return early
|
||||
|
||||
## get healthy deployments
|
||||
### get all deployments
|
||||
healthy_deployments = self._get_all_deployments(model_name=model, team_id=request_team_id)
|
||||
healthy_deployments = (
|
||||
_routing_group_deployments
|
||||
if _routing_group_deployments is not None
|
||||
else self._get_all_deployments(model_name=model, team_id=request_team_id)
|
||||
)
|
||||
_pre_model_access_group_filter_len: Final = len(healthy_deployments)
|
||||
healthy_deployments = self._filter_deployments_by_model_access_groups(
|
||||
model=model,
|
||||
|
|
@ -10679,7 +10850,12 @@ class Router:
|
|||
model
|
||||
] # update the model to the actual value if an alias has been passed in
|
||||
|
||||
return model, healthy_deployments
|
||||
marker_flags: Final = tuple(self._is_strategy_marker_deployment(d) for d in healthy_deployments)
|
||||
if all(marker_flags) or not any(marker_flags):
|
||||
return model, healthy_deployments
|
||||
return model, [ # mutable-ok: matches this function's list contract expected by downstream filters
|
||||
d for d, is_marker in zip(healthy_deployments, marker_flags, strict=True) if not is_marker
|
||||
]
|
||||
|
||||
def _filter_deployments_by_model_access_groups(
|
||||
self,
|
||||
|
|
@ -11192,11 +11368,26 @@ class Router:
|
|||
|
||||
return filtered
|
||||
|
||||
def _select_pre_routing_strategy(self, model: str, request_kwargs: dict) -> "PreRoutingStrategy | None":
|
||||
def _model_name_has_plain_deployments(self, model: str) -> bool:
|
||||
indices: Final = self.model_name_to_deployment_indices.get(model) or ()
|
||||
return any(not self._is_strategy_marker_deployment(self.model_list[idx]) for idx in indices)
|
||||
|
||||
def _select_pre_routing_strategy(
|
||||
self, model: str, request_kwargs: dict
|
||||
) -> "TaggedPreRoutingStrategy[PreRoutingStrategy] | None":
|
||||
"""
|
||||
Resolve the pre-routing strategy for `model`, disambiguating deployments
|
||||
that share a `model_name` by matching the request's tags against each
|
||||
registered strategy's tags before falling back to the first registered.
|
||||
Returns the tagged registry entry so the caller can tell whether the
|
||||
request's tags were what selected it, and can locate the marker
|
||||
deployment the strategy was registered from via its (model_name, tags)
|
||||
pair.
|
||||
|
||||
With tag filtering enabled, strategies that all carry real tags matching
|
||||
none of the request's do not capture it when the name also has plain
|
||||
deployments: returning None hands the request to ordinary tag-aware
|
||||
deployment selection.
|
||||
"""
|
||||
candidates: Final[list[TaggedPreRoutingStrategy[PreRoutingStrategy]]] = [
|
||||
*self.auto_routers.get(model, []),
|
||||
|
|
@ -11206,8 +11397,6 @@ class Router:
|
|||
]
|
||||
if not candidates:
|
||||
return None
|
||||
if len(candidates) == 1:
|
||||
return candidates[0].strategy
|
||||
|
||||
request_tags: Final = _get_tags_from_request_kwargs(request_kwargs)
|
||||
if request_tags:
|
||||
|
|
@ -11215,11 +11404,17 @@ class Router:
|
|||
if tagged.tags and is_valid_deployment_tag(
|
||||
list(tagged.tags), request_tags, self.tag_filtering_match_any
|
||||
):
|
||||
return tagged.strategy
|
||||
return tagged
|
||||
for tagged in candidates:
|
||||
if "default" in tagged.tags:
|
||||
return tagged.strategy
|
||||
return candidates[0].strategy
|
||||
return tagged
|
||||
if (
|
||||
self.enable_tag_filtering
|
||||
and all(tagged.tags for tagged in candidates)
|
||||
and self._model_name_has_plain_deployments(model)
|
||||
):
|
||||
return None
|
||||
return candidates[0]
|
||||
|
||||
async def async_pre_routing_hook(
|
||||
self,
|
||||
|
|
@ -11243,15 +11438,18 @@ class Router:
|
|||
if self.routing_plugins:
|
||||
await self._run_routing_plugins(model=model, request_kwargs=request_kwargs, messages=messages)
|
||||
|
||||
router_strategy: Final = self._select_pre_routing_strategy(model=model, request_kwargs=request_kwargs)
|
||||
if router_strategy is None:
|
||||
selected_strategy: Final = self._select_pre_routing_strategy(model=model, request_kwargs=request_kwargs)
|
||||
if selected_strategy is None:
|
||||
self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
|
||||
self._stamp_or_clear_metadata_key(
|
||||
request_kwargs=request_kwargs, key=SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY, value=None
|
||||
)
|
||||
self._stamp_or_clear_metadata_key(
|
||||
request_kwargs=request_kwargs, key=CONSUMED_REQUEST_TAGS_METADATA_KEY, value=None
|
||||
)
|
||||
return None
|
||||
|
||||
pre_routing_hook_response: Final = await router_strategy.async_pre_routing_hook(
|
||||
pre_routing_hook_response: Final = await selected_strategy.strategy.async_pre_routing_hook(
|
||||
model=model,
|
||||
request_kwargs=request_kwargs,
|
||||
messages=messages,
|
||||
|
|
@ -11267,24 +11465,80 @@ class Router:
|
|||
key=SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
|
||||
value=(pre_routing_hook_response.session_affinity_ttl_seconds if pre_routing_hook_response else None),
|
||||
)
|
||||
self._stamp_or_clear_metadata_key(
|
||||
request_kwargs=request_kwargs,
|
||||
key=CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
value=self._consumed_request_tags_stamp(
|
||||
selected_strategy=selected_strategy,
|
||||
pre_routing_hook_response=pre_routing_hook_response,
|
||||
request_tags=_get_tags_from_request_kwargs(request_kwargs),
|
||||
),
|
||||
)
|
||||
|
||||
# `model` (the alias, e.g. "smart-router") is never the deployment actually
|
||||
# called - apply the alias's own litellm_params (besides `model` itself,
|
||||
# which is just the alias marker) to the request, since the tier/route
|
||||
# deployment the hook selected won't have them. Router-only fields
|
||||
# (tpm, rpm, weight, complexity_router_config, ...) are excluded from the
|
||||
# actual outbound LLM call downstream by litellm.types.utils.all_litellm_params,
|
||||
# not here.
|
||||
# called - apply the router marker's own litellm_params to the request,
|
||||
# since the tier/route deployment the hook selected won't have them. The
|
||||
# marker entry is looked up by its `auto_router/` model prefix and the
|
||||
# selected strategy's tags, never by list position: plain deployments may
|
||||
# share the alias `model_name` and must not leak their params (`api_base`,
|
||||
# `api_key`, ...) onto the routed call. Router-only fields (tpm, rpm,
|
||||
# weight, complexity_router_config, ...) are excluded from the actual
|
||||
# outbound LLM call downstream by litellm.types.utils.all_litellm_params,
|
||||
# not here. Custom pricing fields ARE call params, so they must be
|
||||
# excluded here: they price the alias, not the deployment the hook
|
||||
# selected, and forwarding them re-registers the routed deployment at
|
||||
# the alias's price (an explicit 0 makes every alias request bill $0).
|
||||
if pre_routing_hook_response is not None:
|
||||
alias_index: Final = self.model_name_to_deployment_indices.get(model, [])
|
||||
if alias_index:
|
||||
alias_litellm_params: Final = self.model_list[alias_index[0]].get("litellm_params", {})
|
||||
for key, value in alias_litellm_params.items():
|
||||
if key != "model" and value is not None:
|
||||
request_kwargs.setdefault(key, value)
|
||||
for key, value in self._forwardable_alias_marker_params(model=model, strategy_tags=selected_strategy.tags):
|
||||
request_kwargs.setdefault(key, value)
|
||||
|
||||
return pre_routing_hook_response
|
||||
|
||||
def _forwardable_alias_marker_params(
|
||||
self, model: str, strategy_tags: tuple[str, ...]
|
||||
) -> tuple[tuple[str, object], ...]:
|
||||
marker_params: Final = tuple(
|
||||
litellm_params
|
||||
for idx in self.model_name_to_deployment_indices.get(model, ())
|
||||
if isinstance(litellm_params := self.model_list[idx].get("litellm_params", {}), dict)
|
||||
and str(litellm_params.get("model", "")).startswith(AUTO_ROUTER_MODEL_PREFIX)
|
||||
)
|
||||
tag_matched: Final = tuple(
|
||||
params for params in marker_params if tuple(params.get("tags") or ()) == strategy_tags
|
||||
)
|
||||
selected: Final = tag_matched[0] if tag_matched else (marker_params[0] if marker_params else None)
|
||||
if selected is None:
|
||||
return ()
|
||||
return tuple(
|
||||
(key, value)
|
||||
for key, value in selected.items()
|
||||
if key not in _ALIAS_PARAMS_NEVER_FORWARDED
|
||||
and key not in CustomPricingLiteLLMParams.model_fields
|
||||
and value is not None
|
||||
)
|
||||
|
||||
def _consumed_request_tags_stamp(
|
||||
self,
|
||||
selected_strategy: "TaggedPreRoutingStrategy[PreRoutingStrategy]",
|
||||
pre_routing_hook_response: PreRoutingHookResponse | None,
|
||||
request_tags: Sequence[str],
|
||||
) -> ConsumedRequestTagsStamp | None:
|
||||
"""Record which tags picked the router and which model group it rewrote to, or None.
|
||||
|
||||
A request whose tags matched the selected strategy's tags has already spent those
|
||||
tags on picking the router; re-applying them to the routed tier's model group would
|
||||
empty the pool unless every tier deployment repeats the marker's tag. Only the
|
||||
strategy's own tags are spent: the request's other tags keep constraining
|
||||
deployment selection inside the routed group, and key/team policy tags are
|
||||
untouched because tag filtering separately re-applies whatever
|
||||
`metadata.inherited_tags` carries for the stamped group.
|
||||
"""
|
||||
if pre_routing_hook_response is None or not selected_strategy.tags or not request_tags:
|
||||
return None
|
||||
if not is_valid_deployment_tag(selected_strategy.tags, request_tags, self.tag_filtering_match_any):
|
||||
return None
|
||||
return ConsumedRequestTagsStamp(model_group=pre_routing_hook_response.model, tags=selected_strategy.tags)
|
||||
|
||||
@staticmethod
|
||||
def _record_routing_decision(
|
||||
request_kwargs: dict,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ from types import MappingProxyType
|
|||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.types.router import RouterErrors
|
||||
from litellm.constants import CONSUMED_REQUEST_TAGS_METADATA_KEY
|
||||
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs
|
||||
from litellm.types.router import ConsumedRequestTagsStamp, RouterErrors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router as _Router
|
||||
|
|
@ -46,7 +48,9 @@ def _is_valid_deployment_tag_regex(
|
|||
return None
|
||||
|
||||
|
||||
def is_valid_deployment_tag(deployment_tags: list[str], request_tags: list[str], match_any: bool = True) -> bool:
|
||||
def is_valid_deployment_tag(
|
||||
deployment_tags: Sequence[str], request_tags: Sequence[str], match_any: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a tag is valid, the matching can be either any or all based on `match_any` flag
|
||||
"""
|
||||
|
|
@ -389,6 +393,25 @@ def _tag_known_to_group(
|
|||
)
|
||||
|
||||
|
||||
def _request_tags_after_router_consumption(metadata: Mapping[Any, Any], model: str) -> Sequence[str] | None:
|
||||
# The pre-routing hook stamps which tags selected the router it rewrote the request
|
||||
# to: those tags already did their job and must not also constrain deployment choice
|
||||
# inside the routed group. The request's other tags still apply there, on top of the
|
||||
# inherited_tags snapshot that keeps key/team policy applying. Every other model
|
||||
# group keeps the full list.
|
||||
stamp: Final = metadata.get(CONSUMED_REQUEST_TAGS_METADATA_KEY)
|
||||
if not isinstance(stamp, ConsumedRequestTagsStamp) or stamp.model_group != model:
|
||||
return metadata.get("tags")
|
||||
request_tags: Final = metadata.get("tags")
|
||||
leftover: Final = tuple(
|
||||
tag for tag in (request_tags if isinstance(request_tags, (list, tuple)) else ()) if tag not in stamp.tags
|
||||
)
|
||||
inherited_tags: Final = metadata.get("inherited_tags")
|
||||
if not isinstance(inherited_tags, (list, tuple)):
|
||||
return leftover or None
|
||||
return tuple(dict.fromkeys((*leftover, *inherited_tags)))
|
||||
|
||||
|
||||
async def get_deployments_for_tag(
|
||||
llm_router_instance: LitellmRouter,
|
||||
model: str, # used to raise the correct error
|
||||
|
|
@ -429,7 +452,7 @@ async def get_deployments_for_tag(
|
|||
verbose_logger.debug("request metadata: %s", request_kwargs.get(metadata_variable_name))
|
||||
if metadata_variable_name in request_kwargs:
|
||||
metadata: Final = request_kwargs[metadata_variable_name]
|
||||
request_tags: Final = metadata.get("tags")
|
||||
request_tags: Final = _request_tags_after_router_consumption(metadata, model)
|
||||
match_any: Final = llm_router_instance.tag_filtering_match_any
|
||||
routing_prefix: Final = llm_router_instance.tag_routing_prefix or ""
|
||||
|
||||
|
|
@ -561,28 +584,49 @@ async def get_deployments_for_tag(
|
|||
return healthy_deployments
|
||||
|
||||
|
||||
def _tags_in_metadata(metadata: object) -> list[str]:
|
||||
"""
|
||||
Tags out of a metadata bucket the caller controls the shape of.
|
||||
|
||||
A request can send its metadata (and its ``tags``) as anything the JSON body
|
||||
allowed, an unparsed string or null included, so any shape that is not a list
|
||||
of string tags carries no tags rather than raising.
|
||||
"""
|
||||
if not isinstance(metadata, Mapping):
|
||||
return []
|
||||
typed_metadata: Final[Mapping[str, object]] = metadata
|
||||
tags: Final = typed_metadata.get("tags")
|
||||
if isinstance(tags, str) or not isinstance(tags, Sequence):
|
||||
return []
|
||||
typed_tags: Final[Sequence[object]] = tags
|
||||
return [tag for tag in typed_tags if isinstance(tag, str)]
|
||||
|
||||
|
||||
def _get_tags_from_request_kwargs(
|
||||
request_kwargs: dict[Any, Any] | None = None,
|
||||
metadata_variable_name: Literal["metadata", "litellm_metadata"] = "metadata",
|
||||
request_kwargs: Mapping[Any, Any] | None = None,
|
||||
metadata_variable_name: Literal["metadata", "litellm_metadata"] | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Helper to get tags from request kwargs
|
||||
|
||||
Args:
|
||||
request_kwargs: The request kwargs to get tags from
|
||||
metadata_variable_name: Which metadata dict holds proxy metadata; resolved
|
||||
from the kwargs when not pinned, so /v1/messages-shaped requests
|
||||
(``litellm_metadata``) read the same bucket the proxy wrote tags to
|
||||
|
||||
Returns:
|
||||
List[str]: The tags from the request kwargs
|
||||
"""
|
||||
if request_kwargs is None:
|
||||
return []
|
||||
if metadata_variable_name in request_kwargs:
|
||||
metadata: Final = request_kwargs[metadata_variable_name] or {}
|
||||
tags = metadata.get("tags", [])
|
||||
return tags if tags is not None else []
|
||||
elif "litellm_params" in request_kwargs:
|
||||
litellm_params: Final = request_kwargs["litellm_params"] or {}
|
||||
_metadata: Final = litellm_params.get(metadata_variable_name, {}) or {}
|
||||
tags = _metadata.get("tags", [])
|
||||
return tags if tags is not None else []
|
||||
resolved_variable_name: Final = metadata_variable_name or get_metadata_variable_name_from_kwargs(request_kwargs)
|
||||
if resolved_variable_name in request_kwargs:
|
||||
return _tags_in_metadata(request_kwargs[resolved_variable_name])
|
||||
if "litellm_params" in request_kwargs:
|
||||
litellm_params: Final = request_kwargs["litellm_params"]
|
||||
if not isinstance(litellm_params, Mapping):
|
||||
return []
|
||||
typed_litellm_params: Final[Mapping[str, object]] = litellm_params
|
||||
return _tags_in_metadata(typed_litellm_params.get(resolved_variable_name))
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -319,6 +319,7 @@ def _should_cooldown_deployment(
|
|||
deployment: str,
|
||||
exception_status: str | int,
|
||||
original_exception: Any,
|
||||
requested_model_group: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Helper that decides if a deployment should be put in cooldown
|
||||
|
|
@ -341,7 +342,9 @@ def _should_cooldown_deployment(
|
|||
model_group: Final = litellm_router_instance.get_model_group(id=deployment)
|
||||
is_single_deployment_model_group = False
|
||||
if model_group is not None and len(model_group) == 1:
|
||||
is_single_deployment_model_group = True
|
||||
is_single_deployment_model_group = not litellm_router_instance.routing_group_has_alternatives(
|
||||
requested_model_group
|
||||
)
|
||||
|
||||
## CHECK DEPLOYMENT-LEVEL POLICY FIRST (overrides router-level)
|
||||
dep_policy, dep_allowed_fails = _get_deployment_cooldown_policy(litellm_router_instance, deployment)
|
||||
|
|
@ -413,6 +416,7 @@ def _set_cooldown_deployments(
|
|||
exception_status: str | int,
|
||||
deployment: str | None = None,
|
||||
time_to_cooldown: float | None = None,
|
||||
requested_model_group: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Add a model to the list of models being cooled down for that minute, if it exceeds the allowed fails / minute
|
||||
|
|
@ -449,6 +453,7 @@ def _set_cooldown_deployments(
|
|||
deployment=deployment,
|
||||
exception_status=exception_status,
|
||||
original_exception=original_exception,
|
||||
requested_model_group=requested_model_group,
|
||||
):
|
||||
litellm_router_instance.cooldown_cache.add_deployment_to_cooldown(
|
||||
model_id=deployment,
|
||||
|
|
|
|||
|
|
@ -16,12 +16,13 @@ class LangfuseOtelConfig(BaseModel):
|
|||
|
||||
class LangfuseSpanAttributes(str, Enum):
|
||||
LANGFUSE_ENVIRONMENT = "langfuse.environment"
|
||||
VERSION = "langfuse.version"
|
||||
RELEASE = "langfuse.release"
|
||||
|
||||
# ---- Generation-level metadata ----
|
||||
GENERATION_NAME = "langfuse.generation.name"
|
||||
GENERATION_ID = "langfuse.generation.id"
|
||||
PARENT_OBSERVATION_ID = "langfuse.generation.parent_observation_id"
|
||||
GENERATION_VERSION = "langfuse.generation.version"
|
||||
MASK_INPUT = "langfuse.generation.mask_input"
|
||||
MASK_OUTPUT = "langfuse.generation.mask_output"
|
||||
|
||||
|
|
@ -36,8 +37,6 @@ class LangfuseSpanAttributes(str, Enum):
|
|||
TRACE_NAME = "langfuse.trace.name"
|
||||
TRACE_ID = "langfuse.trace.id"
|
||||
TRACE_METADATA = "langfuse.trace.metadata"
|
||||
TRACE_VERSION = "langfuse.trace.version"
|
||||
TRACE_RELEASE = "langfuse.trace.release"
|
||||
EXISTING_TRACE_ID = "langfuse.trace.existing_id"
|
||||
UPDATE_TRACE_KEYS = "langfuse.trace.update_keys"
|
||||
|
||||
|
|
|
|||
|
|
@ -13,3 +13,5 @@ class UsagePerChunk(TypedDict):
|
|||
completion_tokens_details: CompletionTokensDetails | None
|
||||
prompt_tokens_details: PromptTokensDetailsWrapper | None
|
||||
cost: float | None
|
||||
inference_geo: str | None
|
||||
speed: str | None
|
||||
|
|
|
|||
|
|
@ -902,6 +902,14 @@ class TaggedPreRoutingStrategy(Generic[_PreRoutingStrategyT_co]):
|
|||
strategy: _PreRoutingStrategyT_co
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsumedRequestTagsStamp:
|
||||
"""The model group a tagged router rewrote to, plus the request tags spent selecting it."""
|
||||
|
||||
model_group: str
|
||||
tags: tuple[str, ...]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PreRoutingStrategy(Protocol):
|
||||
"""Structural interface shared by the auto / complexity / adaptive / quality routers."""
|
||||
|
|
|
|||
|
|
@ -9707,6 +9707,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -9830,6 +9831,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -9922,6 +9924,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -10007,6 +10010,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -10410,6 +10414,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -10624,6 +10629,7 @@
|
|||
"output_cost_per_token": 1.85e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -10701,6 +10707,7 @@
|
|||
"output_cost_per_token": 1.85e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -11262,6 +11269,7 @@
|
|||
"output_cost_per_token": 1.85e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -12940,6 +12948,103 @@
|
|||
"supports_system_messages": true,
|
||||
"supports_tool_choice": false
|
||||
},
|
||||
"dashscope/deepseek-v4-flash": {
|
||||
"cache_read_input_token_cost": 4e-08,
|
||||
"input_cost_per_token": 2e-07,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 393216,
|
||||
"max_tokens": 393216,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-07,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"dashscope/deepseek-v4-flash-0731": {
|
||||
"cache_read_input_token_cost": 4e-08,
|
||||
"input_cost_per_token": 2e-07,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 393216,
|
||||
"max_tokens": 393216,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-07,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"dashscope/deepseek-v4-pro": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"input_cost_per_token": 2.4e-06,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 393216,
|
||||
"max_tokens": 393216,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.8e-06,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"dashscope/glm-5.1": {
|
||||
"cache_read_input_token_cost": 2.6e-07,
|
||||
"input_cost_per_token": 1.4e-06,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 202745,
|
||||
"max_output_tokens": 131072,
|
||||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.4e-06,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"dashscope/glm-5.2": {
|
||||
"cache_read_input_token_cost": 2.8e-07,
|
||||
"input_cost_per_token": 1.4e-06,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 131072,
|
||||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.4e-06,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"dashscope/kimi-k2.7-code": {
|
||||
"cache_read_input_token_cost": 1.9e-07,
|
||||
"input_cost_per_token": 9.5e-07,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 229376,
|
||||
"max_output_tokens": 16384,
|
||||
"max_tokens": 16384,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-06,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"dashscope/qwen-coder": {
|
||||
"input_cost_per_token": 3e-07,
|
||||
"litellm_provider": "dashscope",
|
||||
|
|
@ -13733,6 +13838,23 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"dashscope/qwen3.8-max": {
|
||||
"cache_read_input_token_cost": 2.5e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "dashscope",
|
||||
"max_input_tokens": 991808,
|
||||
"max_output_tokens": 131072,
|
||||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-06,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"dashscope/qwq-plus": {
|
||||
"input_cost_per_token": 8e-07,
|
||||
"litellm_provider": "dashscope",
|
||||
|
|
@ -15430,6 +15552,17 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_function_calling": true
|
||||
},
|
||||
"deepinfra/nvidia/NVIDIA-Nemotron-3.5-Lightning": {
|
||||
"max_input_tokens": 262144,
|
||||
"input_cost_per_token": 5e-08,
|
||||
"output_cost_per_token": 2e-07,
|
||||
"litellm_provider": "deepinfra",
|
||||
"mode": "chat",
|
||||
"source": "https://deepinfra.com/nvidia/NVIDIA-Nemotron-3.5-Lightning",
|
||||
"supports_tool_choice": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"deepinfra/nvidia/NVIDIA-Nemotron-Nano-9B-v2": {
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -18888,6 +19021,60 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"vertex_ai/gemini-3.7-flash": {
|
||||
"cache_read_input_token_cost": 7.5e-08,
|
||||
"cache_read_input_token_cost_flex": 3.75e-08,
|
||||
"input_cost_per_token": 7.5e-07,
|
||||
"input_cost_per_token_batches": 3.75e-07,
|
||||
"input_cost_per_token_flex": 3.75e-07,
|
||||
"litellm_provider": "vertex_ai",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_reasoning_token": 3.75e-06,
|
||||
"output_cost_per_token": 3.75e-06,
|
||||
"output_cost_per_token_batches": 1.875e-06,
|
||||
"output_cost_per_token_flex": 1.875e-06,
|
||||
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_input": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_url_context": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"supports_native_streaming": true,
|
||||
"input_cost_per_token_priority": 1.35e-06,
|
||||
"output_cost_per_token_priority": 6.75e-06,
|
||||
"cache_read_input_token_cost_priority": 1.35e-07,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_low": 0.014,
|
||||
"search_context_size_medium": 0.014,
|
||||
"search_context_size_high": 0.014
|
||||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"vertex_ai/gemini-3.1-pro-preview": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
|
||||
|
|
@ -20563,6 +20750,63 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini/gemini-3.7-flash": {
|
||||
"cache_read_input_token_cost": 7.5e-08,
|
||||
"cache_read_input_token_cost_flex": 3.75e-08,
|
||||
"input_cost_per_token": 7.5e-07,
|
||||
"input_cost_per_token_batches": 3.75e-07,
|
||||
"input_cost_per_token_flex": 3.75e-07,
|
||||
"litellm_provider": "gemini",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_reasoning_token": 3.75e-06,
|
||||
"output_cost_per_token": 3.75e-06,
|
||||
"output_cost_per_token_batches": 1.875e-06,
|
||||
"output_cost_per_token_flex": 1.875e-06,
|
||||
"rpm": 2000,
|
||||
"source": "https://ai.google.dev/pricing/gemini-3",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_output": false,
|
||||
"supports_audio_input": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_url_context": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"supports_native_streaming": true,
|
||||
"tpm": 800000,
|
||||
"input_cost_per_token_priority": 1.35e-06,
|
||||
"output_cost_per_token_priority": 6.75e-06,
|
||||
"cache_read_input_token_cost_priority": 1.35e-07,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_low": 0.014,
|
||||
"search_context_size_medium": 0.014,
|
||||
"search_context_size_high": 0.014
|
||||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini/gemini-omni-flash-preview": {
|
||||
"input_cost_per_audio_token": 1.5e-06,
|
||||
"input_cost_per_token": 1.5e-06,
|
||||
|
|
@ -20898,6 +21142,61 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini-3.7-flash": {
|
||||
"cache_read_input_token_cost": 7.5e-08,
|
||||
"cache_read_input_token_cost_flex": 3.75e-08,
|
||||
"input_cost_per_token": 7.5e-07,
|
||||
"input_cost_per_token_batches": 3.75e-07,
|
||||
"input_cost_per_token_flex": 3.75e-07,
|
||||
"litellm_provider": "vertex_ai-language-models",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_reasoning_token": 3.75e-06,
|
||||
"output_cost_per_token": 3.75e-06,
|
||||
"output_cost_per_token_batches": 1.875e-06,
|
||||
"output_cost_per_token_flex": 1.875e-06,
|
||||
"source": "https://ai.google.dev/pricing/gemini-3",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_output": false,
|
||||
"supports_audio_input": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_url_context": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"supports_native_streaming": true,
|
||||
"input_cost_per_token_priority": 1.35e-06,
|
||||
"output_cost_per_token_priority": 6.75e-06,
|
||||
"cache_read_input_token_cost_priority": 1.35e-07,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_low": 0.014,
|
||||
"search_context_size_medium": 0.014,
|
||||
"search_context_size_high": 0.014
|
||||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini/gemini-2.5-pro-preview-tts": {
|
||||
"cache_read_input_token_cost": 1.25e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
|
||||
|
|
@ -25987,11 +26286,12 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"groq/llama-3.1-8b-instant": {
|
||||
"deprecation_date": "2026-08-16",
|
||||
"input_cost_per_token": 5e-08,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 8192,
|
||||
"max_tokens": 8192,
|
||||
"max_input_tokens": 131072,
|
||||
"max_output_tokens": 131072,
|
||||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 8e-08,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -25999,9 +26299,10 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"groq/llama-3.3-70b-versatile": {
|
||||
"deprecation_date": "2026-08-16",
|
||||
"input_cost_per_token": 5.9e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 128000,
|
||||
"max_input_tokens": 131072,
|
||||
"max_output_tokens": 32768,
|
||||
"max_tokens": 32768,
|
||||
"mode": "chat",
|
||||
|
|
@ -26022,7 +26323,28 @@
|
|||
"supports_response_schema": false,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"groq/meta-llama/llama-prompt-guard-2-22m": {
|
||||
"input_cost_per_token": 3e-08,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 512,
|
||||
"max_output_tokens": 512,
|
||||
"max_tokens": 512,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-08,
|
||||
"source": "https://console.groq.com/docs/models"
|
||||
},
|
||||
"groq/meta-llama/llama-prompt-guard-2-86m": {
|
||||
"input_cost_per_token": 4e-08,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 512,
|
||||
"max_output_tokens": 512,
|
||||
"max_tokens": 512,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-08,
|
||||
"source": "https://console.groq.com/docs/model/meta-llama/llama-prompt-guard-2-86m"
|
||||
},
|
||||
"groq/meta-llama/llama-guard-4-12b": {
|
||||
"deprecation_date": "2026-03-05",
|
||||
"input_cost_per_token": 2e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 8192,
|
||||
|
|
@ -26032,6 +26354,7 @@
|
|||
"output_cost_per_token": 2e-07
|
||||
},
|
||||
"groq/meta-llama/llama-4-maverick-17b-128e-instruct": {
|
||||
"deprecation_date": "2026-03-09",
|
||||
"input_cost_per_token": 2e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -26045,6 +26368,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"groq/meta-llama/llama-4-scout-17b-16e-instruct": {
|
||||
"deprecation_date": "2026-07-17",
|
||||
"input_cost_per_token": 1.1e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -26058,6 +26382,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"groq/moonshotai/kimi-k2-instruct-0905": {
|
||||
"deprecation_date": "2026-04-15",
|
||||
"input_cost_per_token": 1e-06,
|
||||
"output_cost_per_token": 3e-06,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -26075,8 +26400,8 @@
|
|||
"input_cost_per_token": 1.5e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131072,
|
||||
"max_output_tokens": 32766,
|
||||
"max_tokens": 32766,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-07,
|
||||
"search_context_cost_per_query": {
|
||||
|
|
@ -26096,8 +26421,8 @@
|
|||
"input_cost_per_token": 7.5e-08,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131072,
|
||||
"max_output_tokens": 32768,
|
||||
"max_tokens": 32768,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-07,
|
||||
"search_context_cost_per_query": {
|
||||
|
|
@ -26132,7 +26457,26 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"groq/canopylabs/orpheus-v1-english": {
|
||||
"input_cost_per_character": 2.2e-05,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 4000,
|
||||
"max_output_tokens": 50000,
|
||||
"max_tokens": 50000,
|
||||
"mode": "audio_speech",
|
||||
"source": "https://console.groq.com/docs/model/canopylabs/orpheus-v1-english"
|
||||
},
|
||||
"groq/canopylabs/orpheus-arabic-saudi": {
|
||||
"input_cost_per_character": 4e-05,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 4000,
|
||||
"max_output_tokens": 50000,
|
||||
"max_tokens": 50000,
|
||||
"mode": "audio_speech",
|
||||
"source": "https://console.groq.com/docs/models"
|
||||
},
|
||||
"groq/playai-tts": {
|
||||
"deprecation_date": "2025-12-31",
|
||||
"input_cost_per_character": 5e-05,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 10000,
|
||||
|
|
@ -26140,7 +26484,23 @@
|
|||
"max_tokens": 10000,
|
||||
"mode": "audio_speech"
|
||||
},
|
||||
"groq/qwen/qwen3.6-27b": {
|
||||
"input_cost_per_token": 6e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131072,
|
||||
"max_output_tokens": 16384,
|
||||
"max_tokens": 16384,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-06,
|
||||
"source": "https://console.groq.com/docs/model/qwen/qwen3.6-27b",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"groq/qwen/qwen3-32b": {
|
||||
"deprecation_date": "2026-07-17",
|
||||
"input_cost_per_token": 2.9e-07,
|
||||
"litellm_provider": "groq",
|
||||
"max_input_tokens": 131000,
|
||||
|
|
@ -31632,6 +31992,17 @@
|
|||
"supports_video_input": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"openrouter/nvidia/nemotron-3.5-lightning": {
|
||||
"input_cost_per_token": 5e-08,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 262144,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2e-07,
|
||||
"source": "https://openrouter.ai/nvidia/nemotron-3.5-lightning",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"openrouter/openai/gpt-3.5-turbo": {
|
||||
"input_cost_per_token": 1.5e-06,
|
||||
"litellm_provider": "openrouter",
|
||||
|
|
@ -35769,6 +36140,7 @@
|
|||
"output_cost_per_token": 1.85e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"eu.deepseek.v3.2": {
|
||||
|
|
@ -35781,6 +36153,7 @@
|
|||
"output_cost_per_token": 2.22e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"us.meta.llama3-1-405b-instruct-v1:0": {
|
||||
|
|
@ -45680,11 +46053,15 @@
|
|||
},
|
||||
"bedrock_mantle/openai.gpt-5.6-sol": {
|
||||
"input_cost_per_token": 5.5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1.1e-05,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.375e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
|
||||
"output_cost_per_token": 3.3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.95e-05,
|
||||
"litellm_provider": "bedrock_mantle",
|
||||
"max_input_tokens": 272000,
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "responses",
|
||||
|
|
@ -45708,11 +46085,15 @@
|
|||
},
|
||||
"bedrock_mantle/openai.gpt-5.6-terra": {
|
||||
"input_cost_per_token": 2.2e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 4.4e-06,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5.5e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4.4e-07,
|
||||
"output_cost_per_token": 1.32e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 1.98e-05,
|
||||
"litellm_provider": "bedrock_mantle",
|
||||
"max_input_tokens": 272000,
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "responses",
|
||||
|
|
@ -45736,11 +46117,15 @@
|
|||
},
|
||||
"bedrock_mantle/openai.gpt-5.6-luna": {
|
||||
"input_cost_per_token": 2.2e-07,
|
||||
"input_cost_per_token_above_272k_tokens": 4.4e-07,
|
||||
"cache_creation_input_token_cost": 2.75e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5.5e-07,
|
||||
"cache_read_input_token_cost": 2.2e-08,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4.4e-08,
|
||||
"output_cost_per_token": 1.32e-06,
|
||||
"output_cost_per_token_above_272k_tokens": 1.98e-06,
|
||||
"litellm_provider": "bedrock_mantle",
|
||||
"max_input_tokens": 272000,
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "responses",
|
||||
|
|
@ -46057,6 +46442,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
@ -46071,6 +46457,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_tool_choice": true,
|
||||
"source": "https://aws.amazon.com/bedrock/pricing/"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ LIT003 noqa suppression without rule codes or without a reason.
|
|||
Required shape: `# noqa: TID251 # <reason>`
|
||||
LIT004 pyright/mypy ignore without bracketed codes or without a reason.
|
||||
Required shape: `# pyright: ignore[reportArgumentType] # <reason>`
|
||||
LIT005 A `# mutable-ok` / `# cast-ok` / `# guard-ok` / `# kwargs-ok`
|
||||
suppression without a reason.
|
||||
LIT005 A `# mutable-ok` / `# cast-ok` / `# guard-ok` / `# kwargs-ok` /
|
||||
`# rebind-ok` / `# writable-ok` suppression without a reason.
|
||||
LIT006 `cast(...)` call. typing.cast is an unchecked assertion (the moral equivalent
|
||||
of TypeScript's `as`); it lies to the type checker with zero runtime guarantee.
|
||||
Validate into a concrete frozen type at the boundary instead.
|
||||
|
|
@ -80,6 +80,15 @@ LIT011 Function-argument mutation: a parameter that is re-bound (`param = ...`,
|
|||
instance), not from re-binding. Method-call mutation (`param.append(x)`) is
|
||||
out of reach without type information; LIT001/LIT002 keep mutable collections
|
||||
off signatures instead. Suppress with `# rebind-ok: <reason>`.
|
||||
LIT012 TypedDict field without a `ReadOnly[...]` qualifier. A writable key lets any
|
||||
holder of the payload rewrite it after construction; qualify every field with
|
||||
`ReadOnly[...]` (PEP 705), which nests freely with Required/NotRequired/
|
||||
Annotated in any order. Detection is name-based, like MUTABLE_COLLECTIONS:
|
||||
a class is a TypedDict when `TypedDict` appears among its bases or when it
|
||||
inherits, transitively within the same module, from a class that has it;
|
||||
the functional form (`X = TypedDict("X", {...})`) is checked too. A base
|
||||
imported from another module is out of reach without import resolution.
|
||||
Suppress with `# writable-ok: <reason>`.
|
||||
|
||||
LIT000 Setup failure: a target file could not be read, or contains a syntax error.
|
||||
Reported as a violation rather than crashing the run.
|
||||
|
|
@ -130,6 +139,11 @@ MUTABLE_CONSTRUCTORS = frozenset((
|
|||
QUALIFIED_CONSTRUCTORS = MUTABLE_CONSTRUCTORS - frozenset(("dict", "list", "set"))
|
||||
FREEZING_WRAPPERS = frozenset(("tuple", "frozenset", "MappingProxyType"))
|
||||
UNSAFE_GUARDS = frozenset(("TypeGuard", "TypeIs"))
|
||||
READONLY_QUALIFIER = "ReadOnly"
|
||||
# Qualifiers ReadOnly may nest under, in any order (PEP 705); for Annotated only the
|
||||
# first argument is type syntax, the rest is metadata and never qualifies the field.
|
||||
FIELD_QUALIFIER_WRAPPERS = frozenset(("Required", "NotRequired", "Annotated"))
|
||||
TYPEDDICT_BASE = "TypedDict"
|
||||
MIN_REASON_LEN = 3
|
||||
|
||||
NOQA_RE = re.compile(
|
||||
|
|
@ -147,6 +161,7 @@ CAST_OK_RE = re.compile(r"#\s*cast-ok(?::\s*(?P<reason>.*))?")
|
|||
GUARD_OK_RE = re.compile(r"#\s*guard-ok(?::\s*(?P<reason>.*))?")
|
||||
KWARGS_OK_RE = re.compile(r"#\s*kwargs-ok(?::\s*(?P<reason>.*))?")
|
||||
REBIND_OK_RE = re.compile(r"#\s*rebind-ok(?::\s*(?P<reason>.*))?")
|
||||
WRITABLE_OK_RE = re.compile(r"#\s*writable-ok(?::\s*(?P<reason>.*))?")
|
||||
|
||||
# Suppression tokens that must each carry a reason (LIT005).
|
||||
OK_SUPPRESSIONS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
|
|
@ -155,6 +170,7 @@ OK_SUPPRESSIONS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|||
("guard-ok", GUARD_OK_RE),
|
||||
("kwargs-ok", KWARGS_OK_RE),
|
||||
("rebind-ok", REBIND_OK_RE),
|
||||
("writable-ok", WRITABLE_OK_RE),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -177,6 +193,7 @@ class Comments:
|
|||
guard_ok_lines: frozenset[int]
|
||||
kwargs_ok_lines: frozenset[int]
|
||||
rebind_ok_lines: frozenset[int]
|
||||
writable_ok_lines: frozenset[int]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -232,7 +249,7 @@ def scan_comments(path: Path, source: str) -> tuple[Comments, tuple[Violation, .
|
|||
# tokenize raises TokenError (EOF mid-construct) or a SyntaxError subclass
|
||||
# (IndentationError / TabError) on malformed source; defer to ast.parse below,
|
||||
# which re-raises and is reported as LIT000 rather than crashing the run.
|
||||
return Comments(frozenset(), frozenset(), frozenset(), frozenset(), frozenset()), ()
|
||||
return Comments(frozenset(), frozenset(), frozenset(), frozenset(), frozenset(), frozenset()), ()
|
||||
|
||||
def _lines_with(regex: re.Pattern[str]) -> frozenset[int]:
|
||||
return frozenset(line for line, text in comment_toks if _valid_ok(regex, text))
|
||||
|
|
@ -244,6 +261,7 @@ def scan_comments(path: Path, source: str) -> tuple[Comments, tuple[Violation, .
|
|||
guard_ok_lines=_lines_with(GUARD_OK_RE),
|
||||
kwargs_ok_lines=_lines_with(KWARGS_OK_RE),
|
||||
rebind_ok_lines=_lines_with(REBIND_OK_RE),
|
||||
writable_ok_lines=_lines_with(WRITABLE_OK_RE),
|
||||
),
|
||||
tuple(v for line, text in comment_toks for v in _comment_violations(path, line, text)),
|
||||
)
|
||||
|
|
@ -828,6 +846,111 @@ def iter_param_violations(path: Path, tree: ast.AST, comments: Comments) -> Iter
|
|||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Writable TypedDict fields (LIT012)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _head_name(node: ast.expr) -> str | None:
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
return node.attr
|
||||
return None
|
||||
|
||||
|
||||
def _base_names(cls: ast.ClassDef) -> frozenset[str]:
|
||||
"""The names of a class's bases; a subscripted base (`Foo[int]`) counts as `Foo`."""
|
||||
return frozenset(
|
||||
name
|
||||
for base in cls.bases
|
||||
for name in (_head_name(base.value if isinstance(base, ast.Subscript) else base),)
|
||||
if name is not None
|
||||
)
|
||||
|
||||
|
||||
def _typeddict_classes(tree: ast.AST) -> tuple[ast.ClassDef, ...]:
|
||||
"""ClassDefs that are TypedDicts: `TypedDict` among the bases, or -- transitively,
|
||||
within this module -- a base that is itself one of these classes. A base defined
|
||||
in another module is invisible here; that subclass goes unchecked."""
|
||||
classes = tuple(node for node in ast.walk(tree) if isinstance(node, ast.ClassDef))
|
||||
bases_of = {cls.name: _base_names(cls) for cls in classes}
|
||||
|
||||
def expand(known: frozenset[str]) -> frozenset[str]:
|
||||
grown = known | frozenset(name for name, bases in bases_of.items() if bases & known)
|
||||
return grown if grown == known else expand(grown)
|
||||
|
||||
names = expand(frozenset((TYPEDDICT_BASE,)))
|
||||
return tuple(cls for cls in classes if cls.name in names)
|
||||
|
||||
|
||||
def _has_readonly_qualifier(annotation: ast.expr) -> bool:
|
||||
"""True iff the annotation is `ReadOnly[...]`, possibly nested under
|
||||
Required/NotRequired/Annotated (in any order) or a string forward reference."""
|
||||
if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
|
||||
try:
|
||||
inner = ast.parse(annotation.value, mode="eval").body
|
||||
except SyntaxError:
|
||||
return False
|
||||
return _has_readonly_qualifier(inner)
|
||||
if not isinstance(annotation, ast.Subscript):
|
||||
return False
|
||||
name = _head_name(annotation.value)
|
||||
if name == READONLY_QUALIFIER:
|
||||
return True
|
||||
if name not in FIELD_QUALIFIER_WRAPPERS:
|
||||
return False
|
||||
if name == "Annotated":
|
||||
if isinstance(annotation.slice, ast.Tuple) and annotation.slice.elts:
|
||||
return _has_readonly_qualifier(annotation.slice.elts[0])
|
||||
return False
|
||||
return _has_readonly_qualifier(annotation.slice)
|
||||
|
||||
|
||||
class _Field(NamedTuple):
|
||||
owner: str
|
||||
name: str
|
||||
annotation: ast.expr
|
||||
line: int
|
||||
|
||||
|
||||
def _class_fields(cls: ast.ClassDef) -> Iterator[_Field]:
|
||||
for stmt in cls.body:
|
||||
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
||||
yield _Field(cls.name, stmt.target.id, stmt.annotation, stmt.lineno)
|
||||
|
||||
|
||||
def _functional_fields(tree: ast.AST) -> Iterator[_Field]:
|
||||
"""Fields of the functional form: `X = TypedDict("X", {"field": type, ...})`."""
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call) or _head_name(node.func) != TYPEDDICT_BASE:
|
||||
continue
|
||||
if len(node.args) < 2 or not isinstance(node.args[1], ast.Dict):
|
||||
continue
|
||||
first = node.args[0]
|
||||
owner = first.value if isinstance(first, ast.Constant) and isinstance(first.value, str) else "<TypedDict>"
|
||||
for key, value in zip(node.args[1].keys, node.args[1].values):
|
||||
if isinstance(key, ast.Constant) and isinstance(key.value, str):
|
||||
yield _Field(owner, key.value, value, value.lineno)
|
||||
|
||||
|
||||
def iter_typeddict_violations(path: Path, tree: ast.AST, comments: Comments) -> Iterator[Violation]:
|
||||
fields = (
|
||||
*(f for cls in _typeddict_classes(tree) for f in _class_fields(cls)),
|
||||
*_functional_fields(tree),
|
||||
)
|
||||
for field in fields:
|
||||
if _has_readonly_qualifier(field.annotation) or field.line in comments.writable_ok_lines:
|
||||
continue
|
||||
yield Violation(
|
||||
path, field.line, "LIT012",
|
||||
f"TypedDict field `{field.name}` of `{field.owner}` is writable: any holder "
|
||||
f"of the payload can rewrite the key after construction. Qualify it as "
|
||||
f"`ReadOnly[...]` (PEP 705; nests freely with Required/NotRequired/Annotated) "
|
||||
f"(suppress: `# writable-ok: <reason>`)",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Driver
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -854,6 +977,7 @@ def check_file(path: Path) -> tuple[Violation, ...]:
|
|||
*iter_construction_violations(path, tree, comments),
|
||||
*iter_final_violations(path, tree, comments),
|
||||
*iter_param_violations(path, tree, comments),
|
||||
*iter_typeddict_violations(path, tree, comments),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ emits is gated: LIT001 (mutable collection in any annotation), LIT002
|
|||
without codes or reason), LIT006 (cast), LIT008 (`**kwargs`), LIT009 (inert
|
||||
`# type: ignore`, dead syntax while enableTypeIgnoreComments is false), LIT010
|
||||
(assignment without a Final declaration; suppress deliberate rebinding with
|
||||
`# rebind-ok: <reason>`), and LIT011 (parameter rebinding or in-place mutation)
|
||||
carry limits at or above their current count to ratchet down; LIT005 (`*-ok`
|
||||
suppression without a reason) is frozen at limit 0 so any net-new reasonless
|
||||
suppression trips the gate; and LIT007 (TypeGuard/TypeIs) is a hard zero.
|
||||
`# rebind-ok: <reason>`), LIT011 (parameter rebinding or in-place mutation), and
|
||||
LIT012 (TypedDict field without a `ReadOnly[...]` qualifier; suppress with
|
||||
`# writable-ok: <reason>`) carry limits at or above their current count to
|
||||
ratchet down; LIT005 (`*-ok` suppression without a reason) is frozen at limit 0
|
||||
so any net-new reasonless suppression trips the gate; and LIT007
|
||||
(TypeGuard/TypeIs) is a hard zero.
|
||||
LIT010 and LIT011 were seeded at 1.5x the count left after the sweep that
|
||||
annotated every never-rebound name with Final, so that headroom is the hard
|
||||
line new code cannot cross.
|
||||
|
|
@ -201,7 +203,8 @@ def cmd_check(base: str) -> None:
|
|||
"Remove the new violations, give each a reason (`# noqa: XXX # <reason>`, "
|
||||
"`# pyright: ignore[rule] # <reason>`, `# mutable-ok: <reason>`, "
|
||||
"`# cast-ok: <reason>`, `# guard-ok: <reason>`, `# kwargs-ok: <reason>`, "
|
||||
"`# rebind-ok: <reason>`), or remove an equal number elsewhere; the ceiling "
|
||||
"`# rebind-ok: <reason>`, `# writable-ok: <reason>`), or remove an equal "
|
||||
"number elsewhere; the ceiling "
|
||||
"is the limit in type-discipline-budget.json."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
Deploys the componentized LiteLLM proxy on AWS:
|
||||
|
||||
- **VPC** with public + private subnets across the AZs you pass in, one NAT gateway
|
||||
- **Aurora Postgres** cluster — one writer instance + one reader instance, **IAM database authentication enabled**
|
||||
- **ElastiCache Redis** (private, replication group with multi-AZ failover and at-rest + in-transit encryption) for caching + rate limiting
|
||||
- **VPC** with public + private subnets across the AZs you pass in, one NAT gateway (skipped when you pass an existing `vpc_id`)
|
||||
- **Aurora Postgres** cluster — one writer instance + one reader instance, **IAM database authentication enabled** (skipped when `create_database = false`)
|
||||
- **ElastiCache Redis** (private, replication group with multi-AZ failover and at-rest + in-transit encryption) for caching + rate limiting (skipped when `create_redis = false`)
|
||||
- **S3 bucket** (private, versioned, SSE-S3) — exposed to gateway + backend as `S3_BUCKET_NAME` / `S3_REGION_NAME` for cache backend, request log archival, and `/v1/files` storage
|
||||
- **Secrets Manager** entries for `LITELLM_MASTER_KEY` (auto-generated, `sk-…`) and the Aurora master password (bootstrap-only)
|
||||
- **ECS Fargate cluster** running three services — `gateway`, `backend`, `ui`
|
||||
|
|
@ -14,6 +14,58 @@ Deploys the componentized LiteLLM proxy on AWS:
|
|||
- Everything else (management API: `/key/*`, `/user/*`, …) → `backend`
|
||||
- **One-off migration task** (`litellm-migrations`) that runs `prisma migrate deploy` from the dedicated `ghcr.io/berriai/litellm-migrations` image
|
||||
|
||||
## Bring your own networking, database, and Redis
|
||||
|
||||
The three infrastructure pieces the stack would otherwise own are each
|
||||
optional, so it can slot into an account where networking and data stores are
|
||||
already provisioned (often by another team, in another Terraform state).
|
||||
|
||||
**Networking.** Set `vpc_id` plus `public_subnet_ids` and `private_subnet_ids`
|
||||
and no VPC, subnet, route table, internet gateway, or NAT gateway is created.
|
||||
The ALB goes in the public subnets, the ECS tasks and any subnet group the
|
||||
stack still needs go in the private ones, and `vpc_cidr` / `azs` go unused.
|
||||
The private subnets need their own egress (NAT gateway, or VPC endpoints
|
||||
covering ECR, S3, CloudWatch Logs, and Secrets Manager) since tasks pull
|
||||
images, resolve secrets, and call LLM providers.
|
||||
|
||||
Security groups stay module-owned in either mode: the ALB group, the tasks
|
||||
group, and the database/cache groups when it creates those. To let the tasks
|
||||
reach infrastructure the module doesn't manage, either allow inbound from the
|
||||
group named by the `task_security_group_id` output, or attach a group of your
|
||||
own with `additional_task_security_group_ids`.
|
||||
|
||||
```hcl
|
||||
vpc_id = "vpc-0123456789abcdef0"
|
||||
public_subnet_ids = ["subnet-aaa", "subnet-bbb"]
|
||||
private_subnet_ids = ["subnet-ccc", "subnet-ddd"]
|
||||
```
|
||||
|
||||
**Database and Redis.** `create_database` and `create_redis` default to `true`
|
||||
(today's behavior). Set one to `false` and pass a connection string to use
|
||||
something you already run: the value lands in a Secrets Manager entry and
|
||||
reaches gateway, backend, and the migration task as `DATABASE_URL` /
|
||||
`REDIS_URL`, both of which outrank the discrete `DATABASE_*` / `REDIS_*` vars
|
||||
in the proxy, so nothing appears in plain text in a task definition.
|
||||
|
||||
```hcl
|
||||
create_database = false
|
||||
database_url = "postgresql://litellm:...@db.internal:5432/litellm"
|
||||
create_redis = false
|
||||
redis_url = "rediss://:...@cache.internal:6379"
|
||||
```
|
||||
|
||||
The schema migration still runs on every apply against an existing database;
|
||||
only the Aurora-specific IAM-user bootstrap drops out, since those credentials
|
||||
are already in the URL.
|
||||
|
||||
Leaving the URL empty runs without the component entirely:
|
||||
|
||||
- No database: no virtual keys, teams, spend tracking, or UI persistence, and
|
||||
`STORE_MODEL_IN_DB` is not set, so models come from `proxy_config`. Requests
|
||||
authenticate with `LITELLM_MASTER_KEY` only.
|
||||
- No Redis: rate limits, budgets, and router cooldowns are per-task rather
|
||||
than cluster-wide, which is only sane at one task per service.
|
||||
|
||||
## Aurora + IAM auth
|
||||
|
||||
The cluster runs with `iam_database_authentication_enabled = true`. Enabling
|
||||
|
|
@ -345,7 +397,7 @@ trial / dev stacks only.
|
|||
|
||||
## Storage and database retention
|
||||
|
||||
Three opt-in tripwires guard against accidental data loss on
|
||||
Two opt-in tripwires guard against accidental data loss on
|
||||
`terraform destroy`:
|
||||
|
||||
- **`skip_final_snapshot`** (Aurora; default `false`) — destroying the
|
||||
|
|
@ -354,6 +406,9 @@ Three opt-in tripwires guard against accidental data loss on
|
|||
`/v1/files` content, and the S3 cache backend; default `false`) —
|
||||
`terraform destroy` against a non-empty bucket fails.
|
||||
|
||||
Neither applies to a database you brought yourself: its lifecycle stays with
|
||||
whoever provisioned it, and `terraform destroy` leaves it alone.
|
||||
|
||||
Flip either to `true` only for ephemeral / CI stacks where you accept
|
||||
losing the contents.
|
||||
|
||||
|
|
@ -365,7 +420,7 @@ losing the contents.
|
|||
| `examples/default/` | Thin root: `aws` provider (with an optional `default_tags` slot for org-wide tags) + a call to the module. The one-command deploy path. |
|
||||
| `variables.tf` | All input variables |
|
||||
| `locals.tf` | Path-prefix lists for ALB routing (mirror of `helm/.../ingress.yaml`) |
|
||||
| `network.tf` | VPC, subnets, IGW, NAT, route tables, security groups |
|
||||
| `network.tf` | VPC, subnets, IGW, NAT, route tables (all optional), security groups |
|
||||
| `secrets.tf` | Secrets Manager entries + random passwords |
|
||||
| `rds.tf` | Aurora Postgres cluster + writer / reader instances |
|
||||
| `redis.tf` | ElastiCache Redis |
|
||||
|
|
|
|||
|
|
@ -3,10 +3,17 @@ resource "aws_lb" "this" {
|
|||
load_balancer_type = "application"
|
||||
internal = false
|
||||
security_groups = [aws_security_group.alb.id]
|
||||
subnets = aws_subnet.public[*].id
|
||||
subnets = local.public_subnet_ids
|
||||
|
||||
idle_timeout = 120
|
||||
|
||||
lifecycle {
|
||||
precondition {
|
||||
condition = length(local.public_subnet_ids) >= 2
|
||||
error_message = "The ALB needs at least 2 public subnets in different AZs. Set `public_subnet_ids` when using `vpc_id`, or list at least 2 `azs` when the module creates the VPC."
|
||||
}
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
|
|
@ -25,7 +32,7 @@ resource "aws_lb_target_group" "gateway" {
|
|||
port = 4000
|
||||
protocol = "HTTP"
|
||||
target_type = "ip"
|
||||
vpc_id = aws_vpc.this.id
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
health_check {
|
||||
path = "/health/readiness"
|
||||
|
|
@ -46,7 +53,7 @@ resource "aws_lb_target_group" "backend" {
|
|||
port = 4001
|
||||
protocol = "HTTP"
|
||||
target_type = "ip"
|
||||
vpc_id = aws_vpc.this.id
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
health_check {
|
||||
path = "/health/readiness"
|
||||
|
|
@ -67,7 +74,7 @@ resource "aws_lb_target_group" "ui" {
|
|||
port = 3000
|
||||
protocol = "HTTP"
|
||||
target_type = "ip"
|
||||
vpc_id = aws_vpc.this.id
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
health_check {
|
||||
path = "/healthz"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
# Auto-runs the two manual steps that used to follow `terraform apply`:
|
||||
#
|
||||
# 1. Create the IAM-authed Postgres user (litellm_app) — uses the postgres:16
|
||||
# image with the master password from Secrets Manager.
|
||||
# image with the master password from Secrets Manager. Only relevant to
|
||||
# the Aurora cluster this module creates, so it is skipped when
|
||||
# create_database = false.
|
||||
# 2. Run prisma migrate deploy — reuses the existing aws_ecs_task_definition
|
||||
# .migrations task def from migrations.tf.
|
||||
# .migrations task def from migrations.tf. Runs against an existing
|
||||
# database too, and only disappears when there is no database at all.
|
||||
#
|
||||
# Both are invoked via `terraform_data` provisioners. Gateway/backend services
|
||||
# in ecs.tf depend on `terraform_data.migration`, so on a fresh apply they
|
||||
|
|
@ -23,13 +26,14 @@
|
|||
# extras — see iam.tf). The DB master password lives in a separate secret used
|
||||
# only here, so we grant access in an additive policy.
|
||||
resource "aws_iam_policy" "bootstrap_secrets" {
|
||||
name = "${local.name}-bootstrap-secrets-access"
|
||||
count = var.create_database ? 1 : 0
|
||||
name = "${local.name}-bootstrap-secrets-access"
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = ["secretsmanager:GetSecretValue"]
|
||||
Resource = [aws_secretsmanager_secret.db_master_password.arn]
|
||||
Resource = [aws_secretsmanager_secret.db_master_password[0].arn]
|
||||
}]
|
||||
})
|
||||
|
||||
|
|
@ -37,12 +41,14 @@ resource "aws_iam_policy" "bootstrap_secrets" {
|
|||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "task_execution_bootstrap_secrets" {
|
||||
count = var.create_database ? 1 : 0
|
||||
role = aws_iam_role.task_execution.name
|
||||
policy_arn = aws_iam_policy.bootstrap_secrets.arn
|
||||
policy_arn = aws_iam_policy.bootstrap_secrets[0].arn
|
||||
}
|
||||
|
||||
# ---------- Bootstrap task def ----------
|
||||
resource "aws_cloudwatch_log_group" "bootstrap_db" {
|
||||
count = var.create_database ? 1 : 0
|
||||
name = "/ecs/${local.name}/bootstrap-db"
|
||||
retention_in_days = var.log_retention_days
|
||||
|
||||
|
|
@ -68,6 +74,7 @@ locals {
|
|||
}
|
||||
|
||||
resource "aws_ecs_task_definition" "bootstrap_db" {
|
||||
count = var.create_database ? 1 : 0
|
||||
family = "${local.name}-bootstrap-db"
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
|
|
@ -82,15 +89,15 @@ resource "aws_ecs_task_definition" "bootstrap_db" {
|
|||
essential = true
|
||||
|
||||
environment = [
|
||||
{ name = "PGHOST", value = aws_rds_cluster.this.endpoint },
|
||||
{ name = "PGPORT", value = tostring(aws_rds_cluster.this.port) },
|
||||
{ name = "PGHOST", value = aws_rds_cluster.this[0].endpoint },
|
||||
{ name = "PGPORT", value = tostring(aws_rds_cluster.this[0].port) },
|
||||
{ name = "PGUSER", value = var.db_master_username },
|
||||
{ name = "PGDATABASE", value = var.db_name },
|
||||
{ name = "BOOTSTRAP_SQL", value = local.bootstrap_sql },
|
||||
]
|
||||
secrets = [
|
||||
# `:password::` extracts the password field out of the JSON secret.
|
||||
{ name = "PGPASSWORD", valueFrom = "${aws_secretsmanager_secret.db_master_password.arn}:password::" },
|
||||
{ name = "PGPASSWORD", valueFrom = "${aws_secretsmanager_secret.db_master_password[0].arn}:password::" },
|
||||
]
|
||||
|
||||
entryPoint = ["sh", "-c"]
|
||||
|
|
@ -99,7 +106,7 @@ resource "aws_ecs_task_definition" "bootstrap_db" {
|
|||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.bootstrap_db.name
|
||||
awslogs-group = aws_cloudwatch_log_group.bootstrap_db[0].name
|
||||
awslogs-region = var.region
|
||||
awslogs-stream-prefix = "bootstrap"
|
||||
}
|
||||
|
|
@ -111,20 +118,22 @@ resource "aws_ecs_task_definition" "bootstrap_db" {
|
|||
|
||||
# ---------- Bootstrap trigger ----------
|
||||
resource "terraform_data" "bootstrap_db" {
|
||||
count = var.create_database ? 1 : 0
|
||||
|
||||
triggers_replace = {
|
||||
cluster_resource_id = aws_rds_cluster.this.cluster_resource_id
|
||||
task_def_revision = aws_ecs_task_definition.bootstrap_db.revision
|
||||
cluster_resource_id = aws_rds_cluster.this[0].cluster_resource_id
|
||||
task_def_revision = aws_ecs_task_definition.bootstrap_db[0].revision
|
||||
}
|
||||
|
||||
provisioner "local-exec" {
|
||||
interpreter = ["bash", "-c"]
|
||||
environment = {
|
||||
CLUSTER = aws_ecs_cluster.this.name
|
||||
TASK_DEF = aws_ecs_task_definition.bootstrap_db.arn
|
||||
SUBNETS = join(",", aws_subnet.private[*].id)
|
||||
SG = aws_security_group.tasks.id
|
||||
TASK_DEF = aws_ecs_task_definition.bootstrap_db[0].arn
|
||||
SUBNETS = join(",", local.private_subnet_ids)
|
||||
SG = join(",", local.task_security_group_ids)
|
||||
REGION = var.region
|
||||
LOG_GRP = aws_cloudwatch_log_group.bootstrap_db.name
|
||||
LOG_GRP = aws_cloudwatch_log_group.bootstrap_db[0].name
|
||||
}
|
||||
command = <<-EOT
|
||||
set -euo pipefail
|
||||
|
|
@ -144,9 +153,13 @@ resource "terraform_data" "bootstrap_db" {
|
|||
EOT
|
||||
}
|
||||
|
||||
# Same secret-by-ARN gap as the migration below. The margin here is wide,
|
||||
# since the writer instance takes minutes while the version write does not,
|
||||
# but both hang off the cluster in parallel and nothing orders them.
|
||||
depends_on = [
|
||||
aws_rds_cluster_instance.writer,
|
||||
aws_iam_role_policy_attachment.task_execution_bootstrap_secrets,
|
||||
aws_secretsmanager_secret_version.db_master_password,
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -154,20 +167,22 @@ resource "terraform_data" "bootstrap_db" {
|
|||
# Reuses the task definition from migrations.tf — this resource just invokes
|
||||
# it and waits.
|
||||
resource "terraform_data" "migration" {
|
||||
count = local.database_enabled ? 1 : 0
|
||||
|
||||
triggers_replace = {
|
||||
task_def_revision = aws_ecs_task_definition.migrations.revision
|
||||
bootstrap_id = terraform_data.bootstrap_db.id
|
||||
task_def_revision = aws_ecs_task_definition.migrations[0].revision
|
||||
bootstrap_id = join(",", terraform_data.bootstrap_db[*].id)
|
||||
}
|
||||
|
||||
provisioner "local-exec" {
|
||||
interpreter = ["bash", "-c"]
|
||||
environment = {
|
||||
CLUSTER = aws_ecs_cluster.this.name
|
||||
TASK_DEF = aws_ecs_task_definition.migrations.arn
|
||||
SUBNETS = join(",", aws_subnet.private[*].id)
|
||||
SG = aws_security_group.tasks.id
|
||||
TASK_DEF = aws_ecs_task_definition.migrations[0].arn
|
||||
SUBNETS = join(",", local.private_subnet_ids)
|
||||
SG = join(",", local.task_security_group_ids)
|
||||
REGION = var.region
|
||||
LOG_GRP = aws_cloudwatch_log_group.migrations.name
|
||||
LOG_GRP = aws_cloudwatch_log_group.migrations[0].name
|
||||
}
|
||||
command = <<-EOT
|
||||
set -euo pipefail
|
||||
|
|
@ -187,5 +202,14 @@ resource "terraform_data" "migration" {
|
|||
EOT
|
||||
}
|
||||
|
||||
depends_on = [terraform_data.bootstrap_db]
|
||||
# A container reads a secret by ARN, so Terraform sees no edge from the
|
||||
# ARN to the _version that gives it a value. The managed-Aurora path hides
|
||||
# that: the cluster create takes long enough that the version always lands
|
||||
# first. A bring-your-own database has nothing slow in between, so without
|
||||
# this the run-task below can fire against a valueless secret and fail the
|
||||
# apply with ResourceInitializationError.
|
||||
depends_on = [
|
||||
terraform_data.bootstrap_db,
|
||||
aws_secretsmanager_secret_version.database_url,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ resource "aws_cloudwatch_log_group" "ui" {
|
|||
}
|
||||
|
||||
resource "aws_cloudwatch_log_group" "migrations" {
|
||||
count = local.database_enabled ? 1 : 0
|
||||
name = "/ecs/${local.name}/migrations"
|
||||
retention_in_days = var.log_retention_days
|
||||
|
||||
|
|
@ -38,11 +39,13 @@ resource "aws_cloudwatch_log_group" "migrations" {
|
|||
}
|
||||
|
||||
# Shared env block fed to gateway, backend, and the migration task. Mirrors
|
||||
# the helm chart's `litellm.serverEnv` helper on the IAM-auth branch:
|
||||
# DATABASE_URL is assembled at runtime by
|
||||
# the helm chart's `litellm.serverEnv` helper on the IAM-auth branch: for the
|
||||
# module-created Aurora, DATABASE_URL is assembled at runtime by
|
||||
# litellm/proxy/auth/rds_iam_token.py::init_iam_db_url_from_env from
|
||||
# HOST/PORT/USER/NAME plus an IAM-signed token, so no DB password is needed
|
||||
# in the task definition.
|
||||
# in the task definition. An existing database instead arrives as a
|
||||
# DATABASE_URL secret (var.database_url), which run.py and the proxy both
|
||||
# take as-is.
|
||||
locals {
|
||||
# OTel v2 is opt-in and gated on otel_endpoint, matching the GCP stack.
|
||||
# When set, LITELLM_OTEL_V2 flips on alongside the OTEL_* block, with
|
||||
|
|
@ -103,29 +106,50 @@ locals {
|
|||
] : [],
|
||||
)
|
||||
|
||||
shared_env = [
|
||||
managed_db_env = var.create_database ? [
|
||||
{ name = "IAM_TOKEN_DB_AUTH", value = "true" },
|
||||
{ name = "DATABASE_HOST", value = aws_rds_cluster.this.endpoint },
|
||||
{ name = "DATABASE_PORT", value = tostring(aws_rds_cluster.this.port) },
|
||||
{ name = "DATABASE_HOST", value = aws_rds_cluster.this[0].endpoint },
|
||||
{ name = "DATABASE_PORT", value = tostring(aws_rds_cluster.this[0].port) },
|
||||
{ name = "DATABASE_USER", value = var.db_username },
|
||||
{ name = "DATABASE_NAME", value = var.db_name },
|
||||
{ name = "DATABASE_HOST_READ_REPLICA", value = aws_rds_cluster.this.reader_endpoint },
|
||||
{ name = "DATABASE_PORT_READ_REPLICA", value = tostring(aws_rds_cluster.this.port) },
|
||||
{ name = "REDIS_HOST", value = aws_elasticache_replication_group.this.primary_endpoint_address },
|
||||
{ name = "REDIS_PORT", value = tostring(aws_elasticache_replication_group.this.port) },
|
||||
{ name = "DATABASE_HOST_READ_REPLICA", value = aws_rds_cluster.this[0].reader_endpoint },
|
||||
{ name = "DATABASE_PORT_READ_REPLICA", value = tostring(aws_rds_cluster.this[0].port) },
|
||||
] : []
|
||||
|
||||
managed_redis_env = var.create_redis ? [
|
||||
{ name = "REDIS_HOST", value = aws_elasticache_replication_group.this[0].primary_endpoint_address },
|
||||
{ name = "REDIS_PORT", value = tostring(aws_elasticache_replication_group.this[0].port) },
|
||||
# transit_encryption_enabled = true on the replication group means the
|
||||
# proxy must connect via rediss://. _redis.get_redis_url_from_environment
|
||||
# honors REDIS_SSL to flip the scheme.
|
||||
{ name = "REDIS_SSL", value = "true" },
|
||||
# S3 bucket — referenced from proxy_config via os.environ/S3_BUCKET_NAME
|
||||
# (e.g. cache backend, request log archival, /files passthrough).
|
||||
{ name = "S3_BUCKET_NAME", value = aws_s3_bucket.this.bucket },
|
||||
{ name = "S3_REGION_NAME", value = var.region },
|
||||
# boto3 inside generate_iam_auth_token reads AWS_REGION_NAME first, then
|
||||
# AWS_REGION. Set both for compatibility.
|
||||
{ name = "AWS_REGION", value = var.region },
|
||||
{ name = "AWS_REGION_NAME", value = var.region },
|
||||
]
|
||||
] : []
|
||||
|
||||
shared_env = concat(
|
||||
local.managed_db_env,
|
||||
local.managed_redis_env,
|
||||
[
|
||||
# S3 bucket — referenced from proxy_config via os.environ/S3_BUCKET_NAME
|
||||
# (e.g. cache backend, request log archival, /files passthrough).
|
||||
{ name = "S3_BUCKET_NAME", value = aws_s3_bucket.this.bucket },
|
||||
{ name = "S3_REGION_NAME", value = var.region },
|
||||
# boto3 inside generate_iam_auth_token reads AWS_REGION_NAME first, then
|
||||
# AWS_REGION. Set both for compatibility.
|
||||
{ name = "AWS_REGION", value = var.region },
|
||||
{ name = "AWS_REGION_NAME", value = var.region },
|
||||
],
|
||||
)
|
||||
|
||||
# DATABASE_URL / REDIS_URL both outrank the discrete host/port vars in the
|
||||
# proxy, so the BYO branch needs nothing removed from shared_env: the
|
||||
# managed_*_env blocks are already empty whenever these are set.
|
||||
byo_database_secrets = local.byo_database ? [
|
||||
{ name = "DATABASE_URL", valueFrom = aws_secretsmanager_secret.database_url[0].arn },
|
||||
] : []
|
||||
|
||||
byo_redis_secrets = local.byo_redis ? [
|
||||
{ name = "REDIS_URL", valueFrom = aws_secretsmanager_secret.redis_url[0].arn },
|
||||
] : []
|
||||
|
||||
shared_secrets = concat(
|
||||
[
|
||||
|
|
@ -134,6 +158,8 @@ locals {
|
|||
var.litellm_license == "" ? [] : [
|
||||
{ name = "LITELLM_LICENSE", valueFrom = aws_secretsmanager_secret.license[0].arn },
|
||||
],
|
||||
local.byo_database_secrets,
|
||||
local.byo_redis_secrets,
|
||||
local.otel_secrets,
|
||||
local.billing_metrics_secrets,
|
||||
)
|
||||
|
|
@ -151,9 +177,11 @@ locals {
|
|||
for k, v in var.backend_extra_env : { name = k, value = v }
|
||||
]
|
||||
|
||||
backend_default_env = [
|
||||
# Storing models in the DB needs a DB. Without one the backend reads its
|
||||
# model list from proxy_config only.
|
||||
backend_default_env = local.database_enabled ? [
|
||||
{ name = "STORE_MODEL_IN_DB", value = "true" },
|
||||
]
|
||||
] : []
|
||||
gateway_extra_secrets_list = [
|
||||
for k, v in var.gateway_extra_secrets : { name = k, valueFrom = v }
|
||||
]
|
||||
|
|
@ -286,8 +314,8 @@ resource "aws_ecs_service" "gateway" {
|
|||
launch_type = "FARGATE"
|
||||
|
||||
network_configuration {
|
||||
subnets = aws_subnet.private[*].id
|
||||
security_groups = [aws_security_group.tasks.id]
|
||||
subnets = local.private_subnet_ids
|
||||
security_groups = local.task_security_group_ids
|
||||
assign_public_ip = false
|
||||
}
|
||||
|
||||
|
|
@ -308,10 +336,20 @@ resource "aws_ecs_service" "gateway" {
|
|||
|
||||
# Don't start until the schema migration has run. Otherwise the proxy
|
||||
# boots, Prisma fails on the missing tables, and ECS thrashes the task.
|
||||
# The _version entries are listed because a task reads its secrets by ARN,
|
||||
# which gives Terraform no edge to the resource that writes the value; the
|
||||
# migration covers that ordering only while a database exists.
|
||||
depends_on = [
|
||||
aws_lb_listener.http,
|
||||
aws_lb_listener.https,
|
||||
terraform_data.migration,
|
||||
aws_secretsmanager_secret_version.master_key,
|
||||
aws_secretsmanager_secret_version.license,
|
||||
aws_secretsmanager_secret_version.database_url,
|
||||
aws_secretsmanager_secret_version.redis_url,
|
||||
aws_secretsmanager_secret_version.billing_metrics_client_cert,
|
||||
aws_secretsmanager_secret_version.billing_metrics_client_key,
|
||||
aws_secretsmanager_secret_version.billing_metrics_ca_cert,
|
||||
]
|
||||
|
||||
tags = local.tags
|
||||
|
|
@ -381,8 +419,8 @@ resource "aws_ecs_service" "backend" {
|
|||
launch_type = "FARGATE"
|
||||
|
||||
network_configuration {
|
||||
subnets = aws_subnet.private[*].id
|
||||
security_groups = [aws_security_group.tasks.id]
|
||||
subnets = local.private_subnet_ids
|
||||
security_groups = local.task_security_group_ids
|
||||
assign_public_ip = false
|
||||
}
|
||||
|
||||
|
|
@ -399,10 +437,20 @@ resource "aws_ecs_service" "backend" {
|
|||
ignore_changes = [desired_count]
|
||||
}
|
||||
|
||||
# Same secret-version ordering as the gateway, plus UI_PASSWORD, which only
|
||||
# the backend consumes.
|
||||
depends_on = [
|
||||
aws_lb_listener.http,
|
||||
aws_lb_listener.https,
|
||||
terraform_data.migration,
|
||||
aws_secretsmanager_secret_version.master_key,
|
||||
aws_secretsmanager_secret_version.license,
|
||||
aws_secretsmanager_secret_version.ui_password,
|
||||
aws_secretsmanager_secret_version.database_url,
|
||||
aws_secretsmanager_secret_version.redis_url,
|
||||
aws_secretsmanager_secret_version.billing_metrics_client_cert,
|
||||
aws_secretsmanager_secret_version.billing_metrics_client_key,
|
||||
aws_secretsmanager_secret_version.billing_metrics_ca_cert,
|
||||
]
|
||||
|
||||
tags = local.tags
|
||||
|
|
@ -451,8 +499,8 @@ resource "aws_ecs_service" "ui" {
|
|||
launch_type = "FARGATE"
|
||||
|
||||
network_configuration {
|
||||
subnets = aws_subnet.private[*].id
|
||||
security_groups = [aws_security_group.tasks.id]
|
||||
subnets = local.private_subnet_ids
|
||||
security_groups = local.task_security_group_ids
|
||||
assign_public_ip = false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,16 @@ module "litellm" {
|
|||
env = var.env
|
||||
azs = var.azs
|
||||
|
||||
vpc_id = var.vpc_id
|
||||
public_subnet_ids = var.public_subnet_ids
|
||||
private_subnet_ids = var.private_subnet_ids
|
||||
additional_task_security_group_ids = var.additional_task_security_group_ids
|
||||
|
||||
create_database = var.create_database
|
||||
database_url = var.database_url
|
||||
create_redis = var.create_redis
|
||||
redis_url = var.redis_url
|
||||
|
||||
litellm_master_key = var.litellm_master_key
|
||||
litellm_license = var.litellm_license
|
||||
ui_password = var.ui_password
|
||||
|
|
|
|||
|
|
@ -13,6 +13,16 @@ output "ecs_cluster" {
|
|||
value = module.litellm.ecs_cluster
|
||||
}
|
||||
|
||||
output "vpc_id" {
|
||||
description = "VPC the stack runs in, whether module-created or supplied."
|
||||
value = module.litellm.vpc_id
|
||||
}
|
||||
|
||||
output "task_security_group_id" {
|
||||
description = "Tasks security group. Allow this inbound on an existing database or Redis."
|
||||
value = module.litellm.task_security_group_id
|
||||
}
|
||||
|
||||
output "aurora_writer_endpoint" {
|
||||
description = "Aurora writer endpoint."
|
||||
value = module.litellm.aurora_writer_endpoint
|
||||
|
|
|
|||
|
|
@ -1,5 +1,35 @@
|
|||
region = "us-west-2"
|
||||
azs = ["us-west-2a", "us-west-2b"]
|
||||
|
||||
# Networking: by default the module creates a VPC, public/private subnets in
|
||||
# each AZ listed here, an internet gateway, a NAT gateway, and route tables.
|
||||
azs = ["us-west-2a", "us-west-2b"]
|
||||
|
||||
# To deploy into networking you already own, drop `azs` and set these
|
||||
# instead. Nothing network-related is created then, so the private subnets
|
||||
# need their own egress for LLM providers, image pulls, and Secrets Manager.
|
||||
# vpc_id = "vpc-0123456789abcdef0"
|
||||
# public_subnet_ids = ["subnet-aaa", "subnet-bbb"]
|
||||
# private_subnet_ids = ["subnet-ccc", "subnet-ddd"]
|
||||
#
|
||||
# The tasks get their own security group either way. To reach a store that
|
||||
# only allows a group you already have, attach it here as well; the
|
||||
# `task_security_group_id` output names the module's own group.
|
||||
# additional_task_security_group_ids = ["sg-0123456789abcdef0"]
|
||||
|
||||
# Data stores: Aurora Postgres and ElastiCache Redis are created by default.
|
||||
# Set create_* = false to point at your own, passing a connection string
|
||||
# (stored in Secrets Manager, injected as DATABASE_URL / REDIS_URL). Make
|
||||
# sure they allow inbound from the stack's tasks security group, which the
|
||||
# `task_security_group_id` output names.
|
||||
# create_database = false
|
||||
# database_url = "postgresql://litellm:...@db.internal:5432/litellm"
|
||||
# create_redis = false
|
||||
# redis_url = "rediss://:...@cache.internal:6379"
|
||||
#
|
||||
# Leaving the URL empty runs without that component: no database means no
|
||||
# virtual keys, spend tracking, or UI persistence (master-key auth only), and
|
||||
# no Redis means rate limits, budgets, and router cooldowns go per-task
|
||||
# instead of cluster-wide.
|
||||
|
||||
# Resource naming: every AWS resource the stack creates is named
|
||||
# `${tenant}-litellm-${env}` (or that plus a per-resource suffix). E.g.
|
||||
|
|
|
|||
|
|
@ -21,8 +21,64 @@ variable "env" {
|
|||
}
|
||||
|
||||
variable "azs" {
|
||||
description = "Availability zones for subnets. At least 2 (RDS + ALB)."
|
||||
description = "Availability zones for the subnets the module creates. At least 2 (RDS + ALB). Unused when vpc_id is set."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
# Bring-your-own networking. Leave vpc_id empty to have the module create the
|
||||
# VPC, subnets, NAT gateway, and route tables.
|
||||
variable "vpc_id" {
|
||||
description = "Existing VPC to deploy into. Empty → module creates its own networking."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "public_subnet_ids" {
|
||||
description = "Existing public subnets for the ALB (≥ 2 AZs). Required with vpc_id."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "private_subnet_ids" {
|
||||
description = "Existing private subnets for tasks, Aurora, and Redis. Required with vpc_id."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "additional_task_security_group_ids" {
|
||||
description = "Extra security groups for the tasks, e.g. one an existing database already allows."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
# Bring-your-own data stores. create_* false with an empty URL runs without
|
||||
# that component: no DB means no key management or spend tracking, no Redis
|
||||
# means per-task rate limits instead of cluster-wide.
|
||||
variable "create_database" {
|
||||
description = "Create the Aurora Postgres cluster. False → use database_url, or run DB-less."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "database_url" {
|
||||
description = "Postgres connection string for an existing database. Read only when create_database = false."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "create_redis" {
|
||||
description = "Create the ElastiCache Redis group. False → use redis_url, or run without Redis."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "redis_url" {
|
||||
description = "Connection string for an existing Redis. Read only when create_redis = false."
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
# Sensitive — prefer TF_VAR_litellm_master_key / TF_VAR_litellm_license /
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ data "aws_iam_policy_document" "secrets_access" {
|
|||
aws_secretsmanager_secret.billing_metrics_client_cert[*].arn,
|
||||
aws_secretsmanager_secret.billing_metrics_client_key[*].arn,
|
||||
aws_secretsmanager_secret.billing_metrics_ca_cert[*].arn,
|
||||
aws_secretsmanager_secret.database_url[*].arn,
|
||||
aws_secretsmanager_secret.redis_url[*].arn,
|
||||
local.extra_secret_arns,
|
||||
var.otel_headers_secret_arn == "" ? [] : [var.otel_headers_secret_arn],
|
||||
)
|
||||
|
|
@ -79,6 +81,9 @@ resource "aws_iam_role_policy_attachment" "task_execution_secrets" {
|
|||
# Assumed by the running container. Gets `rds-db:connect` so the proxy can
|
||||
# mint IAM-signed Postgres tokens for the app user. Layer additional
|
||||
# policies here (e.g. Bedrock invoke, S3 read) when the proxy needs them.
|
||||
# IAM auth only applies to the Aurora cluster this module creates: an
|
||||
# existing database is reached with the credentials embedded in
|
||||
# var.database_url, so the policy is skipped there.
|
||||
|
||||
resource "aws_iam_role" "task" {
|
||||
name = "${local.name}-task"
|
||||
|
|
@ -90,24 +95,28 @@ resource "aws_iam_role" "task" {
|
|||
data "aws_caller_identity" "current" {}
|
||||
|
||||
data "aws_iam_policy_document" "rds_iam_connect" {
|
||||
count = var.create_database ? 1 : 0
|
||||
|
||||
statement {
|
||||
actions = ["rds-db:connect"]
|
||||
resources = [
|
||||
"arn:aws:rds-db:${var.region}:${data.aws_caller_identity.current.account_id}:dbuser:${aws_rds_cluster.this.cluster_resource_id}/${var.db_username}",
|
||||
"arn:aws:rds-db:${var.region}:${data.aws_caller_identity.current.account_id}:dbuser:${aws_rds_cluster.this[0].cluster_resource_id}/${var.db_username}",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_policy" "rds_iam_connect" {
|
||||
count = var.create_database ? 1 : 0
|
||||
name = "${local.name}-rds-iam-connect"
|
||||
policy = data.aws_iam_policy_document.rds_iam_connect.json
|
||||
policy = data.aws_iam_policy_document.rds_iam_connect[0].json
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "task_rds_iam_connect" {
|
||||
count = var.create_database ? 1 : 0
|
||||
role = aws_iam_role.task.name
|
||||
policy_arn = aws_iam_policy.rds_iam_connect.arn
|
||||
policy_arn = aws_iam_policy.rds_iam_connect[0].arn
|
||||
}
|
||||
|
||||
# ---------- UI task role ----------
|
||||
|
|
|
|||
|
|
@ -25,6 +25,36 @@ locals {
|
|||
var.tags,
|
||||
)
|
||||
|
||||
# Networking, database, and cache are each either module-owned or
|
||||
# bring-your-own. Everything downstream reads these locals rather than the
|
||||
# resources, so a resource going to zero instances doesn't ripple.
|
||||
create_vpc = var.vpc_id == ""
|
||||
vpc_id = local.create_vpc ? aws_vpc.this[0].id : var.vpc_id
|
||||
public_subnet_ids = local.create_vpc ? aws_subnet.public[*].id : var.public_subnet_ids
|
||||
private_subnet_ids = local.create_vpc ? aws_subnet.private[*].id : var.private_subnet_ids
|
||||
|
||||
task_security_group_ids = concat([aws_security_group.tasks.id], var.additional_task_security_group_ids)
|
||||
|
||||
# `byo_*` is the existing-store branch, `database_enabled` is either branch.
|
||||
# Neither branch means the component is absent: no DB (no key management,
|
||||
# spend tracking, or UI persistence) or no Redis (per-task rate limits and
|
||||
# cooldowns instead of cluster-wide).
|
||||
# nonsensitive() on the emptiness check only: without it the sensitivity of
|
||||
# the URLs propagates into every value derived from these flags, redacting
|
||||
# unrelated task-definition and output diffs in the plan.
|
||||
byo_database = !var.create_database && nonsensitive(var.database_url != "")
|
||||
byo_redis = !var.create_redis && nonsensitive(var.redis_url != "")
|
||||
database_enabled = var.create_database || local.byo_database
|
||||
redis_enabled = var.create_redis || local.byo_redis
|
||||
|
||||
# Aurora and ElastiCache subnet groups both demand two AZs, so supplied
|
||||
# private subnets have to cover two whenever either store is module-created.
|
||||
managed_stores_need_two_azs = var.create_database || var.create_redis
|
||||
|
||||
# Every uvicorn worker in every gateway task counts its own rate limits when
|
||||
# there is no Redis to share them through, so the ceiling is tasks x workers.
|
||||
max_gateway_processes = (var.gateway_autoscaling_enabled ? var.gateway_max_capacity : var.gateway_desired_count) * var.gateway_num_workers
|
||||
|
||||
gateway_path_prefixes = [
|
||||
"/v1/chat/*", "/chat/*",
|
||||
"/v1/completions*", "/completions*",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
# every apply (after the IAM-authed user has been created). The
|
||||
# `migration_run_command` output is preserved for break-glass manual re-runs.
|
||||
resource "aws_ecs_task_definition" "migrations" {
|
||||
count = local.database_enabled ? 1 : 0
|
||||
family = "${local.name}-migrations"
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
|
|
@ -32,11 +33,12 @@ resource "aws_ecs_task_definition" "migrations" {
|
|||
|
||||
# No entryPoint/command override — the image's ENTRYPOINT runs run.py.
|
||||
environment = local.shared_env
|
||||
secrets = local.byo_database_secrets
|
||||
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.migrations.name
|
||||
awslogs-group = aws_cloudwatch_log_group.migrations[0].name
|
||||
awslogs-region = var.region
|
||||
awslogs-stream-prefix = "migrations"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,34 @@
|
|||
data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
# Networking is created only when the caller didn't supply a VPC. With
|
||||
# var.vpc_id set, every resource in this file except the security groups has
|
||||
# zero instances and the stack consumes the caller's subnets through
|
||||
# local.public_subnet_ids / local.private_subnet_ids (see locals.tf).
|
||||
|
||||
resource "aws_vpc" "this" {
|
||||
count = local.create_vpc ? 1 : 0
|
||||
cidr_block = var.vpc_cidr
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
|
||||
lifecycle {
|
||||
precondition {
|
||||
condition = length(var.azs) >= 2
|
||||
error_message = "Provide at least 2 availability zones in `azs`, or set `vpc_id` + `public_subnet_ids` + `private_subnet_ids` to deploy into an existing VPC."
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(local.tags, { Name = local.name })
|
||||
}
|
||||
|
||||
resource "aws_internet_gateway" "this" {
|
||||
vpc_id = aws_vpc.this.id
|
||||
count = local.create_vpc ? 1 : 0
|
||||
vpc_id = aws_vpc.this[0].id
|
||||
tags = merge(local.tags, { Name = local.name })
|
||||
}
|
||||
|
||||
# Public subnets (ALB + NAT). One per AZ.
|
||||
resource "aws_subnet" "public" {
|
||||
count = length(var.azs)
|
||||
vpc_id = aws_vpc.this.id
|
||||
count = local.create_vpc ? length(var.azs) : 0
|
||||
vpc_id = aws_vpc.this[0].id
|
||||
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
|
||||
availability_zone = var.azs[count.index]
|
||||
map_public_ip_on_launch = true
|
||||
|
|
@ -29,8 +39,8 @@ resource "aws_subnet" "public" {
|
|||
# Private subnets (ECS tasks, RDS, ElastiCache). One per AZ, separate from
|
||||
# public range.
|
||||
resource "aws_subnet" "private" {
|
||||
count = length(var.azs)
|
||||
vpc_id = aws_vpc.this.id
|
||||
count = local.create_vpc ? length(var.azs) : 0
|
||||
vpc_id = aws_vpc.this[0].id
|
||||
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
|
||||
availability_zone = var.azs[count.index]
|
||||
|
||||
|
|
@ -38,6 +48,7 @@ resource "aws_subnet" "private" {
|
|||
}
|
||||
|
||||
resource "aws_eip" "nat" {
|
||||
count = local.create_vpc ? 1 : 0
|
||||
domain = "vpc"
|
||||
tags = merge(local.tags, { Name = "${local.name}-nat" })
|
||||
|
||||
|
|
@ -47,7 +58,8 @@ resource "aws_eip" "nat" {
|
|||
# Single NAT gateway in the first public subnet. For HA, replicate per AZ —
|
||||
# adds ~$30/mo per gateway, so off by default for a baseline deployment.
|
||||
resource "aws_nat_gateway" "this" {
|
||||
allocation_id = aws_eip.nat.id
|
||||
count = local.create_vpc ? 1 : 0
|
||||
allocation_id = aws_eip.nat[0].id
|
||||
subnet_id = aws_subnet.public[0].id
|
||||
|
||||
tags = merge(local.tags, { Name = local.name })
|
||||
|
|
@ -56,45 +68,53 @@ resource "aws_nat_gateway" "this" {
|
|||
}
|
||||
|
||||
resource "aws_route_table" "public" {
|
||||
vpc_id = aws_vpc.this.id
|
||||
count = local.create_vpc ? 1 : 0
|
||||
vpc_id = aws_vpc.this[0].id
|
||||
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
gateway_id = aws_internet_gateway.this.id
|
||||
gateway_id = aws_internet_gateway.this[0].id
|
||||
}
|
||||
|
||||
tags = merge(local.tags, { Name = "${local.name}-public" })
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "public" {
|
||||
count = length(var.azs)
|
||||
count = local.create_vpc ? length(var.azs) : 0
|
||||
subnet_id = aws_subnet.public[count.index].id
|
||||
route_table_id = aws_route_table.public.id
|
||||
route_table_id = aws_route_table.public[0].id
|
||||
}
|
||||
|
||||
resource "aws_route_table" "private" {
|
||||
vpc_id = aws_vpc.this.id
|
||||
count = local.create_vpc ? 1 : 0
|
||||
vpc_id = aws_vpc.this[0].id
|
||||
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
nat_gateway_id = aws_nat_gateway.this.id
|
||||
nat_gateway_id = aws_nat_gateway.this[0].id
|
||||
}
|
||||
|
||||
tags = merge(local.tags, { Name = "${local.name}-private" })
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "private" {
|
||||
count = length(var.azs)
|
||||
count = local.create_vpc ? length(var.azs) : 0
|
||||
subnet_id = aws_subnet.private[count.index].id
|
||||
route_table_id = aws_route_table.private.id
|
||||
route_table_id = aws_route_table.private[0].id
|
||||
}
|
||||
|
||||
# ---------- Security groups ----------
|
||||
#
|
||||
# Always module-owned, in local.vpc_id, so the stack keeps a least-privilege
|
||||
# path between its own components even when it borrows someone else's VPC.
|
||||
# Existing databases and caches reached over var.database_url / var.redis_url
|
||||
# need to allow inbound from the tasks group (or from a group passed via
|
||||
# var.additional_task_security_group_ids).
|
||||
|
||||
resource "aws_security_group" "alb" {
|
||||
name = "${local.name}-alb"
|
||||
description = "Inbound HTTP/HTTPS to the LiteLLM ALB."
|
||||
vpc_id = aws_vpc.this.id
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "HTTP from anywhere"
|
||||
|
|
@ -126,7 +146,7 @@ resource "aws_security_group" "alb" {
|
|||
resource "aws_security_group" "tasks" {
|
||||
name = "${local.name}-tasks"
|
||||
description = "ECS tasks (gateway/backend/ui)."
|
||||
vpc_id = aws_vpc.this.id
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "ALB to tasks"
|
||||
|
|
@ -144,13 +164,23 @@ resource "aws_security_group" "tasks" {
|
|||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
# The tasks group is created in every mode, so this is where the
|
||||
# bring-your-own-VPC inputs get checked.
|
||||
lifecycle {
|
||||
precondition {
|
||||
condition = local.create_vpc || length(var.private_subnet_ids) >= (local.managed_stores_need_two_azs ? 2 : 1)
|
||||
error_message = "`private_subnet_ids` is required when `vpc_id` is set: the tasks, Aurora, and ElastiCache all live in private subnets. Aurora and ElastiCache subnet groups need subnets in at least 2 AZs, so pass 2 unless both `create_database` and `create_redis` are false."
|
||||
}
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_security_group" "rds" {
|
||||
count = var.create_database ? 1 : 0
|
||||
name = "${local.name}-rds"
|
||||
description = "RDS Postgres - tasks only."
|
||||
vpc_id = aws_vpc.this.id
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "Postgres from ECS tasks"
|
||||
|
|
@ -164,9 +194,10 @@ resource "aws_security_group" "rds" {
|
|||
}
|
||||
|
||||
resource "aws_security_group" "redis" {
|
||||
count = var.create_redis ? 1 : 0
|
||||
name = "${local.name}-redis"
|
||||
description = "ElastiCache Redis - tasks only."
|
||||
vpc_id = aws_vpc.this.id
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
ingress {
|
||||
description = "Redis from ECS tasks"
|
||||
|
|
|
|||
|
|
@ -13,19 +13,29 @@ output "ecs_cluster" {
|
|||
value = aws_ecs_cluster.this.name
|
||||
}
|
||||
|
||||
output "vpc_id" {
|
||||
description = "VPC the stack runs in, whether module-created or passed in via `vpc_id`."
|
||||
value = local.vpc_id
|
||||
}
|
||||
|
||||
output "task_security_group_id" {
|
||||
description = "Security group attached to the ECS tasks. Allow inbound from this group on an existing database or Redis reached over `database_url` / `redis_url`."
|
||||
value = aws_security_group.tasks.id
|
||||
}
|
||||
|
||||
output "aurora_writer_endpoint" {
|
||||
description = "Aurora writer endpoint (cluster endpoint). Used by gateway/backend as DATABASE_HOST."
|
||||
value = aws_rds_cluster.this.endpoint
|
||||
description = "Aurora writer endpoint (cluster endpoint). Used by gateway/backend as DATABASE_HOST. Null when `create_database = false`."
|
||||
value = one(aws_rds_cluster.this[*].endpoint)
|
||||
}
|
||||
|
||||
output "aurora_reader_endpoint" {
|
||||
description = "Aurora reader endpoint. Used by gateway/backend as DATABASE_HOST_READ_REPLICA."
|
||||
value = aws_rds_cluster.this.reader_endpoint
|
||||
description = "Aurora reader endpoint. Used by gateway/backend as DATABASE_HOST_READ_REPLICA. Null when `create_database = false`."
|
||||
value = one(aws_rds_cluster.this[*].reader_endpoint)
|
||||
}
|
||||
|
||||
output "redis_endpoint" {
|
||||
description = "ElastiCache Redis primary endpoint (TLS, transit_encryption_enabled = true)."
|
||||
value = "${aws_elasticache_replication_group.this.primary_endpoint_address}:${aws_elasticache_replication_group.this.port}"
|
||||
description = "ElastiCache Redis primary endpoint (TLS, transit_encryption_enabled = true). Null when `create_redis = false`."
|
||||
value = one([for r in aws_elasticache_replication_group.this : "${r.primary_endpoint_address}:${r.port}"])
|
||||
}
|
||||
|
||||
output "s3_bucket" {
|
||||
|
|
@ -39,15 +49,17 @@ output "master_key_secret_arn" {
|
|||
}
|
||||
|
||||
output "db_master_password_secret_arn" {
|
||||
description = "Secrets Manager ARN holding the Aurora master credentials (bootstrap-only). Used to create the IAM-authed application user."
|
||||
value = aws_secretsmanager_secret.db_master_password.arn
|
||||
description = "Secrets Manager ARN holding the Aurora master credentials (bootstrap-only). Used to create the IAM-authed application user. Null when `create_database = false`."
|
||||
value = one(aws_secretsmanager_secret.db_master_password[*].arn)
|
||||
}
|
||||
|
||||
# Pre-baked SQL to run once as the master user, creating the IAM-authed
|
||||
# application user that gateway/backend/migration tasks will authenticate as.
|
||||
# Irrelevant to an existing database reached over `database_url`, whose
|
||||
# credentials are already in the URL.
|
||||
output "db_bootstrap_sql" {
|
||||
description = "Run this once as the master DB user (after the first apply) to create the IAM-authed app user."
|
||||
value = <<-SQL
|
||||
description = "Run this once as the master DB user (after the first apply) to create the IAM-authed app user. Empty when `create_database = false`."
|
||||
value = !var.create_database ? "" : <<-SQL
|
||||
CREATE USER ${var.db_username};
|
||||
GRANT rds_iam TO ${var.db_username};
|
||||
GRANT ALL PRIVILEGES ON DATABASE ${var.db_name} TO ${var.db_username};
|
||||
|
|
@ -60,13 +72,13 @@ output "db_bootstrap_sql" {
|
|||
# Pre-baked command for running the one-off migration task. ECS run-task
|
||||
# needs the subnet + SG IDs at call time, so we render the full command.
|
||||
output "migration_run_command" {
|
||||
description = "Shell command that runs the one-off prisma migration task against Aurora. Run this once, after the bootstrap SQL above, before sending traffic."
|
||||
value = format(
|
||||
description = "Shell command that runs the one-off prisma migration task against the database. Run this once, after the bootstrap SQL above, before sending traffic. Empty when the stack has no database."
|
||||
value = !local.database_enabled ? "" : format(
|
||||
"aws ecs run-task --cluster %s --launch-type FARGATE --task-definition %s --network-configuration 'awsvpcConfiguration={subnets=[%s],securityGroups=[%s],assignPublicIp=DISABLED}' --region %s",
|
||||
aws_ecs_cluster.this.name,
|
||||
aws_ecs_task_definition.migrations.arn,
|
||||
join(",", aws_subnet.private[*].id),
|
||||
aws_security_group.tasks.id,
|
||||
aws_ecs_task_definition.migrations[0].arn,
|
||||
join(",", local.private_subnet_ids),
|
||||
join(",", local.task_security_group_ids),
|
||||
var.region,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Aurora Postgres cluster with one writer + one reader instance, IAM
|
||||
# database authentication enabled.
|
||||
# database authentication enabled. Skipped entirely when
|
||||
# create_database = false, in which case the stack either talks to the
|
||||
# database named by var.database_url or runs without one.
|
||||
#
|
||||
# Important: enabling IAM auth on the cluster does not by itself grant any
|
||||
# Postgres user the ability to log in with an IAM token. After the first
|
||||
|
|
@ -17,13 +19,15 @@
|
|||
# superusers — keep it for break-glass only.
|
||||
|
||||
resource "aws_db_subnet_group" "this" {
|
||||
count = var.create_database ? 1 : 0
|
||||
name = "${local.name}-db"
|
||||
subnet_ids = aws_subnet.private[*].id
|
||||
subnet_ids = local.private_subnet_ids
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_rds_cluster_parameter_group" "this" {
|
||||
count = var.create_database ? 1 : 0
|
||||
name = "${local.name}-cluster-pg"
|
||||
family = "aurora-postgresql${split(".", var.db_engine_version)[0]}"
|
||||
description = "LiteLLM Aurora Postgres cluster parameters."
|
||||
|
|
@ -32,16 +36,17 @@ resource "aws_rds_cluster_parameter_group" "this" {
|
|||
}
|
||||
|
||||
resource "aws_rds_cluster" "this" {
|
||||
count = var.create_database ? 1 : 0
|
||||
cluster_identifier = local.name
|
||||
engine = "aurora-postgresql"
|
||||
engine_mode = "provisioned"
|
||||
engine_version = var.db_engine_version
|
||||
database_name = var.db_name
|
||||
master_username = var.db_master_username
|
||||
master_password = random_password.db_master_password.result
|
||||
db_subnet_group_name = aws_db_subnet_group.this.name
|
||||
vpc_security_group_ids = [aws_security_group.rds.id]
|
||||
db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.this.name
|
||||
master_password = random_password.db_master_password[0].result
|
||||
db_subnet_group_name = aws_db_subnet_group.this[0].name
|
||||
vpc_security_group_ids = [aws_security_group.rds[0].id]
|
||||
db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.this[0].name
|
||||
|
||||
iam_database_authentication_enabled = true
|
||||
storage_encrypted = true
|
||||
|
|
@ -61,11 +66,12 @@ resource "aws_rds_cluster" "this" {
|
|||
}
|
||||
|
||||
resource "aws_rds_cluster_instance" "writer" {
|
||||
count = var.create_database ? 1 : 0
|
||||
identifier = "${local.name}-writer"
|
||||
cluster_identifier = aws_rds_cluster.this.id
|
||||
cluster_identifier = aws_rds_cluster.this[0].id
|
||||
instance_class = var.db_instance_class
|
||||
engine = aws_rds_cluster.this.engine
|
||||
engine_version = aws_rds_cluster.this.engine_version
|
||||
engine = aws_rds_cluster.this[0].engine
|
||||
engine_version = aws_rds_cluster.this[0].engine_version
|
||||
|
||||
publicly_accessible = false
|
||||
performance_insights_enabled = true
|
||||
|
|
@ -78,11 +84,12 @@ resource "aws_rds_cluster_instance" "writer" {
|
|||
}
|
||||
|
||||
resource "aws_rds_cluster_instance" "reader" {
|
||||
count = var.create_database ? 1 : 0
|
||||
identifier = "${local.name}-reader"
|
||||
cluster_identifier = aws_rds_cluster.this.id
|
||||
cluster_identifier = aws_rds_cluster.this[0].id
|
||||
instance_class = var.db_instance_class
|
||||
engine = aws_rds_cluster.this.engine
|
||||
engine_version = aws_rds_cluster.this.engine_version
|
||||
engine = aws_rds_cluster.this[0].engine
|
||||
engine_version = aws_rds_cluster.this[0].engine_version
|
||||
|
||||
publicly_accessible = false
|
||||
performance_insights_enabled = true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
resource "aws_elasticache_subnet_group" "this" {
|
||||
count = var.create_redis ? 1 : 0
|
||||
name = "${local.name}-redis"
|
||||
subnet_ids = aws_subnet.private[*].id
|
||||
subnet_ids = local.private_subnet_ids
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
|
@ -13,6 +14,7 @@ resource "aws_elasticache_subnet_group" "this" {
|
|||
# TLS-protected — the proxy connects via the rediss:// scheme thanks to
|
||||
# REDIS_SSL=true in the shared task env (see ecs.tf).
|
||||
resource "aws_elasticache_replication_group" "this" {
|
||||
count = var.create_redis ? 1 : 0
|
||||
replication_group_id = "${local.name}-redis"
|
||||
description = "LiteLLM ElastiCache Redis"
|
||||
|
||||
|
|
@ -23,8 +25,8 @@ resource "aws_elasticache_replication_group" "this" {
|
|||
parameter_group_name = "default.redis7"
|
||||
port = 6379
|
||||
|
||||
subnet_group_name = aws_elasticache_subnet_group.this.name
|
||||
security_group_ids = [aws_security_group.redis.id]
|
||||
subnet_group_name = aws_elasticache_subnet_group.this[0].name
|
||||
security_group_ids = [aws_security_group.redis[0].id]
|
||||
|
||||
automatic_failover_enabled = var.redis_num_replicas >= 1
|
||||
multi_az_enabled = var.redis_num_replicas >= 1
|
||||
|
|
@ -35,3 +37,15 @@ resource "aws_elasticache_replication_group" "this" {
|
|||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
# Rate limits, budgets, and router cooldowns are shared through Redis. Without
|
||||
# it each gateway process counts on its own, so a caller spread across tasks
|
||||
# collects the full per-key allowance from every one of them. A `check` rather
|
||||
# than a precondition: running without Redis is a legitimate choice when you do
|
||||
# not rely on per-key limits, so this warns instead of blocking the plan.
|
||||
check "redis_less_rate_limits_are_per_process" {
|
||||
assert {
|
||||
condition = local.redis_enabled || local.max_gateway_processes <= 1
|
||||
error_message = "No Redis is configured while the gateway can run up to ${local.max_gateway_processes} processes, so per-key RPM/TPM limits, budgets, and cooldowns apply per process and a caller can multiply them across tasks. Set `create_redis = true`, pass `redis_url`, or hold the gateway to one process (`gateway_autoscaling_enabled = false`, `gateway_desired_count = 1`, `gateway_num_workers = 1`)."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ resource "random_password" "master_key" {
|
|||
# user (see rds.tf header). Runtime services authenticate via IAM tokens
|
||||
# and never read this secret.
|
||||
resource "random_password" "db_master_password" {
|
||||
count = var.create_database ? 1 : 0
|
||||
length = 32
|
||||
special = false
|
||||
min_lower = 4
|
||||
|
|
@ -130,6 +131,7 @@ resource "aws_secretsmanager_secret_version" "billing_metrics_ca_cert" {
|
|||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "db_master_password" {
|
||||
count = var.create_database ? 1 : 0
|
||||
name = "${local.name}-db-master-password"
|
||||
description = "Aurora master-user password - bootstrap only. Runtime auth is IAM-token."
|
||||
recovery_window_in_days = 0
|
||||
|
|
@ -138,12 +140,50 @@ resource "aws_secretsmanager_secret" "db_master_password" {
|
|||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "db_master_password" {
|
||||
secret_id = aws_secretsmanager_secret.db_master_password.id
|
||||
count = var.create_database ? 1 : 0
|
||||
secret_id = aws_secretsmanager_secret.db_master_password[0].id
|
||||
secret_string = jsonencode({
|
||||
username = var.db_master_username
|
||||
password = random_password.db_master_password.result
|
||||
host = aws_rds_cluster.this.endpoint
|
||||
port = aws_rds_cluster.this.port
|
||||
password = random_password.db_master_password[0].result
|
||||
host = aws_rds_cluster.this[0].endpoint
|
||||
port = aws_rds_cluster.this[0].port
|
||||
dbname = var.db_name
|
||||
})
|
||||
}
|
||||
|
||||
# Bring-your-own connection strings. Both hold credentials, so they go to
|
||||
# Secrets Manager and reach the containers as ECS `secrets` rather than as
|
||||
# plain-text env in the task definition.
|
||||
resource "aws_secretsmanager_secret" "database_url" {
|
||||
count = local.byo_database ? 1 : 0
|
||||
|
||||
name = "${local.name}-database-url"
|
||||
description = "DATABASE_URL for an existing Postgres, used when create_database = false."
|
||||
recovery_window_in_days = 0
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "database_url" {
|
||||
count = local.byo_database ? 1 : 0
|
||||
|
||||
secret_id = aws_secretsmanager_secret.database_url[0].id
|
||||
secret_string = var.database_url
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "redis_url" {
|
||||
count = local.byo_redis ? 1 : 0
|
||||
|
||||
name = "${local.name}-redis-url"
|
||||
description = "REDIS_URL for an existing Redis, used when create_redis = false."
|
||||
recovery_window_in_days = 0
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "redis_url" {
|
||||
count = local.byo_redis ? 1 : 0
|
||||
|
||||
secret_id = aws_secretsmanager_secret.redis_url[0].id
|
||||
secret_string = var.redis_url
|
||||
}
|
||||
|
|
|
|||
272
terraform/litellm/aws/tests/byo_infrastructure.tftest.hcl
Normal file
272
terraform/litellm/aws/tests/byo_infrastructure.tftest.hcl
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Plan-only coverage for the four networking/database/cache permutations.
|
||||
# `mock_provider` keeps this offline: no AWS credentials, no API calls, no
|
||||
# resources. Run from terraform/litellm/aws with `terraform test`.
|
||||
|
||||
mock_provider "aws" {
|
||||
# IAM policy documents are validated as JSON by the provider, so the
|
||||
# generated placeholder string has to be replaced with a parsable one.
|
||||
mock_data "aws_iam_policy_document" {
|
||||
defaults = {
|
||||
json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_provider "random" {}
|
||||
|
||||
variables {
|
||||
region = "us-east-1"
|
||||
tenant = "acme"
|
||||
env = "test"
|
||||
allow_plaintext_alb = true
|
||||
}
|
||||
|
||||
run "module_owns_everything_by_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
azs = ["us-east-1a", "us-east-1b"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(aws_vpc.this) == 1 && length(aws_nat_gateway.this) == 1 && length(aws_subnet.private) == 2
|
||||
error_message = "The default path must still create its own VPC, NAT gateway, and one private subnet per AZ."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(aws_rds_cluster.this) == 1 && length(aws_elasticache_replication_group.this) == 1
|
||||
error_message = "The default path must still create Aurora and ElastiCache."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(aws_secretsmanager_secret.database_url) == 0 && length(aws_secretsmanager_secret.redis_url) == 0
|
||||
error_message = "Connection-string secrets belong to the bring-your-own path only."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(local.managed_db_env) == 7 && length(local.managed_redis_env) == 3
|
||||
error_message = "Gateway, backend, and migration tasks must keep the discrete DATABASE_*/REDIS_* env for the module-created stores."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(terraform_data.bootstrap_db) == 1 && length(aws_ecs_task_definition.migrations) == 1
|
||||
error_message = "The IAM-user bootstrap and the schema migration must both run against the module-created Aurora."
|
||||
}
|
||||
}
|
||||
|
||||
run "existing_vpc_creates_no_networking" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
vpc_id = "vpc-00000000000000001"
|
||||
public_subnet_ids = ["subnet-pub-a", "subnet-pub-b"]
|
||||
private_subnet_ids = ["subnet-priv-a", "subnet-priv-b"]
|
||||
additional_task_security_group_ids = ["sg-caller-owned"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = alltrue([
|
||||
length(aws_vpc.this) == 0,
|
||||
length(aws_subnet.public) == 0,
|
||||
length(aws_subnet.private) == 0,
|
||||
length(aws_internet_gateway.this) == 0,
|
||||
length(aws_nat_gateway.this) == 0,
|
||||
length(aws_eip.nat) == 0,
|
||||
length(aws_route_table.public) == 0,
|
||||
length(aws_route_table.private) == 0,
|
||||
])
|
||||
error_message = "An existing vpc_id must suppress every network resource, including the route tables and NAT gateway."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = aws_lb.this.subnets == toset(var.public_subnet_ids)
|
||||
error_message = "The ALB must land in the caller's public subnets."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = alltrue([
|
||||
aws_db_subnet_group.this[0].subnet_ids == toset(var.private_subnet_ids),
|
||||
aws_elasticache_subnet_group.this[0].subnet_ids == toset(var.private_subnet_ids),
|
||||
aws_ecs_service.gateway.network_configuration[0].subnets == toset(var.private_subnet_ids),
|
||||
])
|
||||
error_message = "Tasks, Aurora, and ElastiCache must land in the caller's private subnets."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(local.task_security_group_ids) == 2
|
||||
error_message = "additional_task_security_group_ids must be attached alongside the module's own tasks group."
|
||||
}
|
||||
}
|
||||
|
||||
run "existing_database_and_redis_replace_the_managed_ones" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
azs = ["us-east-1a", "us-east-1b"]
|
||||
create_database = false
|
||||
database_url = "postgresql://litellm:pw@db.internal:5432/litellm"
|
||||
create_redis = false
|
||||
redis_url = "rediss://:pw@cache.internal:6379"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = alltrue([
|
||||
length(aws_rds_cluster.this) == 0,
|
||||
length(aws_rds_cluster_instance.writer) == 0,
|
||||
length(aws_db_subnet_group.this) == 0,
|
||||
length(aws_security_group.rds) == 0,
|
||||
length(aws_elasticache_replication_group.this) == 0,
|
||||
length(aws_elasticache_subnet_group.this) == 0,
|
||||
length(aws_security_group.redis) == 0,
|
||||
])
|
||||
error_message = "Pointing at an existing database and cache must create neither Aurora nor ElastiCache."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(local.managed_db_env) == 0 && length(local.managed_redis_env) == 0
|
||||
error_message = "The discrete DATABASE_*/REDIS_* env vars must be dropped so DATABASE_URL/REDIS_URL are the only connection targets."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = alltrue([
|
||||
length([for s in local.shared_secrets : s if s.name == "DATABASE_URL"]) == 1,
|
||||
length([for s in local.shared_secrets : s if s.name == "REDIS_URL"]) == 1,
|
||||
])
|
||||
error_message = "Both connection strings must reach the containers as Secrets Manager references, not plain-text env."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(terraform_data.bootstrap_db) == 0 && length(aws_ecs_task_definition.migrations) == 1
|
||||
error_message = "An existing database still needs the schema migration, but not the Aurora IAM-user bootstrap."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length([for e in local.backend_default_env : e if e.name == "STORE_MODEL_IN_DB"]) == 1
|
||||
error_message = "STORE_MODEL_IN_DB must stay set when a database is reachable."
|
||||
}
|
||||
}
|
||||
|
||||
run "vpc_without_subnets_fails_at_plan" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
vpc_id = "vpc-00000000000000001"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
aws_lb.this,
|
||||
aws_security_group.tasks,
|
||||
]
|
||||
}
|
||||
|
||||
run "neither_vpc_nor_azs_fails_at_plan" {
|
||||
command = plan
|
||||
|
||||
expect_failures = [
|
||||
aws_vpc.this,
|
||||
]
|
||||
}
|
||||
|
||||
# Aurora and ElastiCache subnet groups need two AZs, so one private subnet is
|
||||
# only enough when neither store is module-created.
|
||||
run "one_private_subnet_fails_while_a_managed_store_needs_two_azs" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
vpc_id = "vpc-00000000000000001"
|
||||
public_subnet_ids = ["subnet-pub-a", "subnet-pub-b"]
|
||||
private_subnet_ids = ["subnet-priv-a"]
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
aws_security_group.tasks,
|
||||
]
|
||||
}
|
||||
|
||||
run "one_private_subnet_is_enough_without_managed_stores" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
vpc_id = "vpc-00000000000000001"
|
||||
public_subnet_ids = ["subnet-pub-a", "subnet-pub-b"]
|
||||
private_subnet_ids = ["subnet-priv-a"]
|
||||
create_database = false
|
||||
create_redis = false
|
||||
# Single process, so the Redis-less rate-limit check stays quiet and this
|
||||
# run is only exercising the subnet rule.
|
||||
gateway_autoscaling_enabled = false
|
||||
gateway_desired_count = 1
|
||||
gateway_num_workers = 1
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(aws_security_group.tasks.vpc_id) > 0
|
||||
error_message = "With no module-created database or cache, a single private subnet must plan cleanly."
|
||||
}
|
||||
}
|
||||
|
||||
# The default sizing is 10 tasks under autoscaling, so a Redis-less stack must
|
||||
# warn that per-key limits are counted per process.
|
||||
run "redis_less_multi_process_gateway_is_flagged" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
azs = ["us-east-1a", "us-east-1b"]
|
||||
create_redis = false
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
check.redis_less_rate_limits_are_per_process,
|
||||
]
|
||||
}
|
||||
|
||||
run "redis_less_single_process_gateway_is_not_flagged" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
azs = ["us-east-1a", "us-east-1b"]
|
||||
create_redis = false
|
||||
gateway_autoscaling_enabled = false
|
||||
gateway_desired_count = 1
|
||||
gateway_num_workers = 1
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.max_gateway_processes == 1
|
||||
error_message = "One task with one worker is a single process, which is the supported way to run without Redis."
|
||||
}
|
||||
}
|
||||
|
||||
run "no_database_and_no_redis_drops_the_schema_migration" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
azs = ["us-east-1a", "us-east-1b"]
|
||||
create_database = false
|
||||
create_redis = false
|
||||
# Single process, so the Redis-less rate-limit check stays quiet here; it
|
||||
# has its own run above.
|
||||
gateway_autoscaling_enabled = false
|
||||
gateway_desired_count = 1
|
||||
gateway_num_workers = 1
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = alltrue([
|
||||
length(aws_ecs_task_definition.migrations) == 0,
|
||||
length(terraform_data.migration) == 0,
|
||||
length(aws_iam_policy.rds_iam_connect) == 0,
|
||||
length(aws_secretsmanager_secret.db_master_password) == 0,
|
||||
])
|
||||
error_message = "With no database at all there is nothing to migrate, bootstrap, or grant rds-db:connect on."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(local.backend_default_env) == 0
|
||||
error_message = "STORE_MODEL_IN_DB must not be set without a database to store models in."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(local.shared_env) == 4
|
||||
error_message = "The shared env must narrow to the S3 bucket and region pair when both data stores are gone."
|
||||
}
|
||||
}
|
||||
|
|
@ -74,20 +74,63 @@ variable "ui_password" {
|
|||
}
|
||||
|
||||
# ---------- Networking ----------
|
||||
#
|
||||
# Two modes:
|
||||
#
|
||||
# 1. Module-owned (default, `vpc_id = ""`): the stack creates a VPC, public
|
||||
# and private subnets per AZ, an internet gateway, a NAT gateway, and
|
||||
# the route tables wiring them together. `vpc_cidr` + `azs` drive it.
|
||||
# 2. Bring-your-own (`vpc_id` set): the stack creates no networking and
|
||||
# places the ALB in `public_subnet_ids` and every task, plus the Aurora
|
||||
# and ElastiCache subnet groups, in `private_subnet_ids`. `vpc_cidr` and
|
||||
# `azs` are then unused.
|
||||
|
||||
variable "vpc_id" {
|
||||
description = <<-EOT
|
||||
Existing VPC to deploy into. Leave empty ("") to have the module create
|
||||
its own VPC, subnets, NAT gateway, and route tables. When set,
|
||||
`public_subnet_ids` and `private_subnet_ids` are required and no
|
||||
networking is created: the private subnets must already have egress
|
||||
(NAT gateway or equivalent) so tasks can reach LLM providers, ECR/GHCR,
|
||||
and Secrets Manager.
|
||||
EOT
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "public_subnet_ids" {
|
||||
description = "Existing public subnets for the ALB, in at least 2 AZs. Required when `vpc_id` is set, ignored otherwise."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "private_subnet_ids" {
|
||||
description = "Existing private subnets for the ECS tasks, Aurora, and ElastiCache. Required when `vpc_id` is set, ignored otherwise."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "additional_task_security_group_ids" {
|
||||
description = <<-EOT
|
||||
Extra security groups to attach to the ECS tasks, on top of the one the
|
||||
module creates. Useful with `vpc_id`: attach a group your existing
|
||||
database or cache already allows inbound from, instead of editing their
|
||||
ingress rules.
|
||||
EOT
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "vpc_cidr" {
|
||||
description = "CIDR block for the VPC."
|
||||
description = "CIDR block for the VPC the module creates. Unused when `vpc_id` is set."
|
||||
type = string
|
||||
default = "10.40.0.0/16"
|
||||
}
|
||||
|
||||
variable "azs" {
|
||||
description = "Availability zones to spread subnets across. At least 2 required for RDS and ALB."
|
||||
description = "Availability zones to spread the module-created subnets across. At least 2 required for Aurora and the ALB. Unused when `vpc_id` is set."
|
||||
type = list(string)
|
||||
validation {
|
||||
condition = length(var.azs) >= 2
|
||||
error_message = "Provide at least 2 availability zones."
|
||||
}
|
||||
default = []
|
||||
}
|
||||
|
||||
# ---------- Component images ----------
|
||||
|
|
@ -279,6 +322,34 @@ variable "ui_cpu_target" {
|
|||
|
||||
# ---------- RDS ----------
|
||||
|
||||
variable "create_database" {
|
||||
description = <<-EOT
|
||||
Create the Aurora Postgres cluster (default). Set false to skip it and
|
||||
either point the stack at an existing database via `database_url`, or
|
||||
run without a database at all when `database_url` is also empty. The
|
||||
DB-less mode drops key management, spend tracking, and the admin UI's
|
||||
persistence: the proxy then serves traffic authenticated by
|
||||
LITELLM_MASTER_KEY only.
|
||||
EOT
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "database_url" {
|
||||
description = <<-EOT
|
||||
Postgres connection string for an existing database, e.g.
|
||||
`postgresql://user:pass@host:5432/litellm`. Only read when
|
||||
`create_database = false`. Stored in a
|
||||
`<tenant>-litellm-<env>-database-url` Secrets Manager entry and injected
|
||||
into gateway, backend, and the migration task as DATABASE_URL, so the
|
||||
value never lands in a task definition. The schema migration still runs
|
||||
against it on every apply.
|
||||
EOT
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "db_instance_class" {
|
||||
description = "Aurora instance class for both writer and reader."
|
||||
type = string
|
||||
|
|
@ -311,6 +382,31 @@ variable "db_username" {
|
|||
|
||||
# ---------- Redis ----------
|
||||
|
||||
variable "create_redis" {
|
||||
description = <<-EOT
|
||||
Create the ElastiCache Redis replication group (default). Set false to
|
||||
skip it and either point the stack at an existing cache via `redis_url`,
|
||||
or run with no Redis at all when `redis_url` is also empty. Without
|
||||
Redis the proxy loses cross-task state: rate limits, budgets, and the
|
||||
router's cooldowns become per-task instead of cluster-wide.
|
||||
EOT
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "redis_url" {
|
||||
description = <<-EOT
|
||||
Connection string for an existing Redis, e.g.
|
||||
`rediss://:password@host:6379`. Only read when `create_redis = false`.
|
||||
Stored in a `<tenant>-litellm-<env>-redis-url` Secrets Manager entry and
|
||||
injected as REDIS_URL, which takes precedence over REDIS_HOST/REDIS_PORT
|
||||
in the proxy.
|
||||
EOT
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "redis_node_type" {
|
||||
description = "ElastiCache node type."
|
||||
type = string
|
||||
|
|
|
|||
|
|
@ -160,25 +160,6 @@ async def test_whisper_log_pre_call():
|
|||
mock_log_pre_call.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whisper_log_pre_call():
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch, MagicMock
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
custom_logger = CustomLogger()
|
||||
|
||||
litellm.callbacks = [custom_logger]
|
||||
|
||||
with patch.object(custom_logger, "log_pre_api_call") as mock_log_pre_call:
|
||||
await litellm.atranscription(
|
||||
model="whisper-1",
|
||||
file=_audio_file(),
|
||||
)
|
||||
mock_log_pre_call.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gpt_4o_transcribe():
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
"""
|
||||
Unit Tests for hosted_vllm Batches and Files API
|
||||
|
||||
Tests the integration of hosted_vllm provider with LiteLLM's batch and file operations.
|
||||
Tests against a real OpenAI-compatible endpoint.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
|
||||
import litellm
|
||||
|
||||
|
||||
SERVER_URL = "https://exampleopenaiendpoint-production-0ee2.up.railway.app/v1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
@pytest.mark.skip(reason="Local only test")
|
||||
async def test_hosted_vllm_full_workflow():
|
||||
"""
|
||||
Test the complete workflow: create file -> create batch -> retrieve batch -> retrieve file.
|
||||
Tests against real OpenAI-compatible endpoint.
|
||||
"""
|
||||
litellm._turn_on_debug()
|
||||
file_name = "openai_batch_completions.jsonl"
|
||||
_current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
file_path = os.path.join(_current_dir, file_name)
|
||||
|
||||
# Step 1: Create file
|
||||
print("\n=== Step 1: Creating file ===")
|
||||
file_obj = await litellm.acreate_file(
|
||||
file=open(file_path, "rb"),
|
||||
purpose="batch",
|
||||
custom_llm_provider="hosted_vllm",
|
||||
api_base=SERVER_URL,
|
||||
api_key="test-api-key",
|
||||
)
|
||||
|
||||
print(f"✓ Created file: {file_obj.id}")
|
||||
assert file_obj.id is not None
|
||||
assert file_obj.object == "file"
|
||||
assert file_obj.purpose == "batch"
|
||||
|
||||
# Step 2: Create batch
|
||||
print("\n=== Step 2: Creating batch ===")
|
||||
batch_obj = await litellm.acreate_batch(
|
||||
completion_window="24h",
|
||||
endpoint="/v1/chat/completions",
|
||||
input_file_id=file_obj.id,
|
||||
custom_llm_provider="hosted_vllm",
|
||||
metadata={"test": "hosted_vllm_integration"},
|
||||
api_base=SERVER_URL,
|
||||
api_key="test-api-key",
|
||||
)
|
||||
|
||||
print(f"✓ Created batch: {batch_obj.id}")
|
||||
print(f" Status: {batch_obj.status}")
|
||||
print(f" Input file: {batch_obj.input_file_id}")
|
||||
assert batch_obj.id is not None
|
||||
assert batch_obj.object == "batch"
|
||||
assert batch_obj.input_file_id == file_obj.id
|
||||
assert batch_obj.endpoint == "/v1/chat/completions"
|
||||
|
||||
# Step 3: Retrieve batch
|
||||
print("\n=== Step 3: Retrieving batch ===")
|
||||
retrieved_batch = await litellm.aretrieve_batch(
|
||||
batch_id=batch_obj.id,
|
||||
custom_llm_provider="hosted_vllm",
|
||||
api_base=SERVER_URL,
|
||||
api_key="test-api-key",
|
||||
)
|
||||
|
||||
print(f"✓ Retrieved batch: {retrieved_batch.id}")
|
||||
print(f" Status: {retrieved_batch.status}")
|
||||
print(f" Output file: {retrieved_batch.output_file_id}")
|
||||
assert retrieved_batch.id == batch_obj.id
|
||||
assert retrieved_batch.object == "batch"
|
||||
assert retrieved_batch.input_file_id == file_obj.id
|
||||
|
||||
# Step 4: Retrieve file (verify file still accessible)
|
||||
print("\n=== Step 4: Retrieving original file ===")
|
||||
retrieved_file = await litellm.afile_retrieve(
|
||||
file_id=file_obj.id,
|
||||
custom_llm_provider="hosted_vllm",
|
||||
api_base=SERVER_URL,
|
||||
api_key="test-api-key",
|
||||
)
|
||||
|
||||
print(f"✓ Retrieved file: {retrieved_file.id}")
|
||||
print(f" Filename: {retrieved_file.filename}")
|
||||
print(f" Bytes: {retrieved_file.bytes}")
|
||||
assert retrieved_file.id == file_obj.id
|
||||
assert retrieved_file.object == "file"
|
||||
|
||||
print("\n✅ Full workflow test completed successfully!")
|
||||
0
tests/e2e/claude_code/_builder_unit_tests/__init__.py
Normal file
0
tests/e2e/claude_code/_builder_unit_tests/__init__.py
Normal file
148
tests/e2e/claude_code/_builder_unit_tests/test_matrix_builder.py
Normal file
148
tests/e2e/claude_code/_builder_unit_tests/test_matrix_builder.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Unit tests for `find_regressions`, the green→red detector that gates
|
||||
auto-merge on the daily compat-matrix docs PR (see `cron_vm/`).
|
||||
|
||||
Markerless harness tests: they exercise publisher plumbing, not a product
|
||||
feature, so they run without a proxy and carry no `e2e` marker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping, Union
|
||||
|
||||
from claude_code.matrix_builder import find_regressions
|
||||
|
||||
_CellSpec = Union[str, Mapping[str, str]]
|
||||
|
||||
|
||||
def _matrix(
|
||||
cells: Mapping[tuple[str, str], _CellSpec],
|
||||
*,
|
||||
names: Mapping[str, str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build a minimal matrix dict from a {(feature_id, provider): status}
|
||||
or {(feature_id, provider): cell_dict} mapping."""
|
||||
names = names or {}
|
||||
features: dict[str, dict[str, dict[str, str]]] = {}
|
||||
for (feature_id, provider), value in cells.items():
|
||||
cell = {"status": value} if isinstance(value, str) else dict(value)
|
||||
features.setdefault(feature_id, {})[provider] = cell
|
||||
return {
|
||||
"features": [
|
||||
{
|
||||
"id": feature_id,
|
||||
"name": names.get(feature_id, feature_id.upper()),
|
||||
"providers": providers,
|
||||
}
|
||||
for feature_id, providers in features.items()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_find_regressions_flags_pass_to_fail() -> None:
|
||||
old = _matrix({("vision", "anthropic"): "pass"})
|
||||
new = _matrix(
|
||||
{("vision", "anthropic"): {"status": "fail", "error": "credit balance too low"}}
|
||||
)
|
||||
regressions = find_regressions(old, new)
|
||||
assert len(regressions) == 1
|
||||
r = regressions[0]
|
||||
assert r["feature_id"] == "vision"
|
||||
assert r["provider"] == "anthropic"
|
||||
assert r["old_status"] == "pass"
|
||||
assert r["new_status"] == "fail"
|
||||
assert r["error"] == "credit balance too low"
|
||||
|
||||
|
||||
def test_find_regressions_ignores_red_to_red() -> None:
|
||||
"""An already-failing cell that stays failing is NOT a regression — a
|
||||
provider that's independently broken (e.g. out of credits) must not
|
||||
block the daily auto-merge forever."""
|
||||
old = _matrix({("vision", "anthropic"): "fail"})
|
||||
new = _matrix({("vision", "anthropic"): "fail"})
|
||||
assert find_regressions(old, new) == []
|
||||
|
||||
|
||||
def test_find_regressions_ignores_improvements_and_steady_green() -> None:
|
||||
old = _matrix(
|
||||
{
|
||||
("vision", "anthropic"): "fail", # red -> green
|
||||
("tool_use", "azure"): "pass", # green -> green
|
||||
}
|
||||
)
|
||||
new = _matrix(
|
||||
{
|
||||
("vision", "anthropic"): "pass",
|
||||
("tool_use", "azure"): "pass",
|
||||
}
|
||||
)
|
||||
assert find_regressions(old, new) == []
|
||||
|
||||
|
||||
def test_find_regressions_ignores_green_to_grey() -> None:
|
||||
"""green→not_tested / green→not_applicable are degradations but not
|
||||
*red* regressions; we deliberately don't block on them."""
|
||||
old = _matrix(
|
||||
{
|
||||
("vision", "azure"): "pass",
|
||||
("tool_use", "azure"): "pass",
|
||||
}
|
||||
)
|
||||
new = _matrix(
|
||||
{
|
||||
("vision", "azure"): "not_tested",
|
||||
("tool_use", "azure"): {"status": "not_applicable", "reason": "skip"},
|
||||
}
|
||||
)
|
||||
assert find_regressions(old, new) == []
|
||||
|
||||
|
||||
def test_find_regressions_ignores_new_cells_without_baseline() -> None:
|
||||
"""A cell only present in the new matrix (new feature/provider) has no
|
||||
baseline, so a fail there can't be a regression."""
|
||||
old = _matrix({("vision", "anthropic"): "pass"})
|
||||
new = _matrix(
|
||||
{
|
||||
("vision", "anthropic"): "pass",
|
||||
("brand_new_feature", "anthropic"): "fail",
|
||||
}
|
||||
)
|
||||
assert find_regressions(old, new) == []
|
||||
|
||||
|
||||
def test_find_regressions_matches_by_id_not_name() -> None:
|
||||
"""Renaming a feature's display name must not hide a regression: cells
|
||||
are matched on the stable id."""
|
||||
old = _matrix({("thinking", "anthropic"): "pass"}, names={"thinking": "Old Name"})
|
||||
new = _matrix(
|
||||
{("thinking", "anthropic"): "fail"}, names={"thinking": "Totally New Name"}
|
||||
)
|
||||
regressions = find_regressions(old, new)
|
||||
assert len(regressions) == 1
|
||||
assert regressions[0]["feature_id"] == "thinking"
|
||||
assert regressions[0]["feature_name"] == "Totally New Name"
|
||||
|
||||
|
||||
def test_find_regressions_reports_multiple_sorted() -> None:
|
||||
old = _matrix(
|
||||
{
|
||||
("vision", "anthropic"): "pass",
|
||||
("tool_use", "anthropic"): "pass",
|
||||
("vision", "azure"): "pass",
|
||||
}
|
||||
)
|
||||
new = _matrix(
|
||||
{
|
||||
("vision", "anthropic"): "fail",
|
||||
("tool_use", "anthropic"): "fail",
|
||||
("vision", "azure"): "pass", # stays green
|
||||
}
|
||||
)
|
||||
regressions = find_regressions(old, new)
|
||||
keys = [(r["feature_id"], r["provider"]) for r in regressions]
|
||||
assert keys == [("tool_use", "anthropic"), ("vision", "anthropic")]
|
||||
|
||||
|
||||
def test_find_regressions_empty_old_matrix_is_safe() -> None:
|
||||
"""No baseline at all (first publish) yields no regressions."""
|
||||
new = _matrix({("vision", "anthropic"): "fail"})
|
||||
assert find_regressions({}, new) == []
|
||||
195
tests/e2e/claude_code/cron_vm/README.md
Normal file
195
tests/e2e/claude_code/cron_vm/README.md
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# Cron VM setup for the Claude Code compatibility-matrix populator
|
||||
|
||||
The populator runs daily on a dedicated GCP VM
|
||||
(`litellm-compatibility-matrix-populator`) rather than as a GitHub
|
||||
Action. Trade-offs:
|
||||
|
||||
- ✅ Real VM means we can `gh auth login` against an account that's
|
||||
already a collaborator on `BerriAI/litellm-docs`, instead of
|
||||
provisioning a GitHub App with `pull-requests: write`.
|
||||
- ✅ Persistent state (a single `~/litellm-cron-worktree/` and its `.venv`)
|
||||
is reused across runs, so each daily run does a fast `git checkout` +
|
||||
incremental `uv sync` rather than a fresh clone + cold sync.
|
||||
- ✅ No Docker dependency — the proxy runs directly via `uv run litellm`.
|
||||
- ⚠️ The VM has to actually be on. systemd's `Persistent=true` recovers
|
||||
from short outages, but a multi-day outage means the matrix goes
|
||||
stale until the VM is back.
|
||||
- ⚠️ Provider credentials live on the VM filesystem
|
||||
(`/etc/litellm-compat-matrix.env`) instead of GitHub secrets. Treat
|
||||
the VM as an environment with comparable blast radius to a CI runner.
|
||||
|
||||
This directory used to live at `tests/claude_code/cron_vm/` (paired with
|
||||
the standalone `tests/claude_code/` suite); it now runs the maintained
|
||||
`tests/e2e/claude_code/` suite instead. The pytest env interface changed
|
||||
accordingly: the runner exports `LITELLM_PROXY_URL` / `LITELLM_MASTER_KEY`
|
||||
(previously `LITELLM_PROXY_BASE_URL` / `LITELLM_PROXY_API_KEY`), the azure
|
||||
column reads `AZURE_AI_API_KEY` / `AZURE_AI_API_BASE` (previously
|
||||
`AZURE_FOUNDRY_*`), and the GPT columns need `OPENAI_API_KEY` and
|
||||
`AZURE_API_BASE` / `AZURE_API_KEY` — see `litellm-compat-matrix.env.example`.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `run_daily.sh` | The actual cron job. Resolves versions, updates the worktree, boots the proxy, runs pytest, builds the JSON, opens (or updates) a docs PR, sweeps stale compat-matrix PRs. |
|
||||
| `build_matrix.py` | Tiny Python CLI that wraps `claude_code.matrix_builder.build_from_paths`. Exists only because the bash script needs *some* way to render the per-cell aggregation, and the builder is already Python. |
|
||||
| `check_regressions.py` | Tiny Python CLI that wraps `claude_code.matrix_builder.find_regressions`. Diffs the freshly built matrix against the currently-published one and exits `3` if any cell flipped green→red, which gates auto-merge. |
|
||||
| `litellm-compat-matrix.service` | systemd oneshot that invokes `run_daily.sh`. |
|
||||
| `litellm-compat-matrix.timer` | `OnCalendar=*-*-* 06:00:00 UTC`, `Persistent=true`. |
|
||||
| `litellm-compat-matrix.env.example` | Template for `/etc/litellm-compat-matrix.env`. |
|
||||
|
||||
## What `run_daily.sh` does
|
||||
|
||||
1. **Resolves the latest LiteLLM final release tag** (newest bare
|
||||
`vX.Y.Z`, skipping `-rc.N`/`-dev.N` pre-releases) by paging the
|
||||
GitHub Releases API (`curl | jq`).
|
||||
2. **Reads the local Claude Code CLI version** via `claude --version`.
|
||||
The cron does not auto-upgrade the CLI — operators do that
|
||||
out-of-band by running `npm install -g @anthropic-ai/claude-code@latest`.
|
||||
3. **Updates the persistent worktree** at `~/litellm-cron-worktree/`:
|
||||
`git fetch --tags --force`, `git reset --hard`,
|
||||
`git clean -fdx -e .venv -e .uv-bin`, `git checkout --force <tag>`.
|
||||
The `.venv` is preserved across runs so `uv sync --frozen` is
|
||||
incremental. Then **shims the test suite**: `tests/e2e/` in the
|
||||
worktree is rebuilt from the dev checkout — the `claude_code/` suite
|
||||
plus the five shared transport helpers it imports (`proxy_client.py`,
|
||||
`e2e_http.py`, `models.py`, `e2e_config.py`, `transport.py`) — so the
|
||||
cron always runs *today's* tests against the latest stable proxy. The
|
||||
tag's own `tests/e2e/` tree (including the EKS-harness `conftest.py`,
|
||||
whose imports the stable venv doesn't install) is deliberately not
|
||||
used.
|
||||
4. **Boots the proxy** as a `setsid` background process on port `4100`
|
||||
(so it can't collide with a developer's `:4000`), then polls
|
||||
`/health/liveliness` until it's up.
|
||||
5. **Runs pytest** on `tests/e2e/claude_code/` with `LITELLM_PROXY_URL`
|
||||
pointed at the proxy and `COMPAT_RESULTS_PATH` set so the conftest
|
||||
hook writes the per-test results artifact. Test failures become
|
||||
`fail` cells in the JSON, not script errors.
|
||||
6. **Builds `compatibility-matrix.json`** by handing the artifact +
|
||||
manifest to `build_matrix.py`.
|
||||
7. **Opens or updates a docs PR**: `gh repo clone` of `litellm-docs`
|
||||
into a tempdir, deterministic head branch
|
||||
(`compat-matrix/<litellm-version>-<claude-code-version>-<UTC-date>`),
|
||||
`--force` push **directly to `BerriAI/litellm-docs`** (the
|
||||
`mateo-berri` token has write access, so this is a same-repo branch,
|
||||
not a fork), `gh pr create`. A re-run on the same day fast-forwards
|
||||
the existing branch and `gh pr create` no-ops ("a pull request for
|
||||
branch ... already exists" is treated as success). These PRs are no
|
||||
longer gated on a second human review.
|
||||
8. **Gates auto-merge on a regression check**: before enabling
|
||||
auto-merge, `check_regressions.py` diffs the new matrix against the
|
||||
one currently on `main`. Auto-merge (`gh pr merge --auto --squash`)
|
||||
is only enabled when **no cell flipped green→red** — i.e. every
|
||||
transition is red→green, green→green, or red→red. A pre-existing red
|
||||
cell (e.g. a provider that's out of API credits) is `red→red` and
|
||||
does **not** block; only a `pass`→`fail` flip does. When a regression
|
||||
is detected the PR is still opened/updated (with a warning banner
|
||||
naming the offending cells) but auto-merge is left **off** — and any
|
||||
auto-merge a prior same-day run enabled is explicitly disabled — so a
|
||||
human reviews before it lands on the public table. The check fails
|
||||
*closed*: if it errors, auto-merge is withheld.
|
||||
9. **Sweeps stale compat-matrix PRs**: once today's PR exists, every
|
||||
other open `compat-matrix/*` PR on the docs repo is closed (and its
|
||||
bot-owned branch deleted), so at most one compat-matrix PR is ever
|
||||
open — the newest.
|
||||
|
||||
## One-time VM setup
|
||||
|
||||
Run as `mateo` on the cron VM:
|
||||
|
||||
```bash
|
||||
# 1. Toolchain
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y git nodejs npm jq curl
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
sudo apt-get install -y gh # or follow https://cli.github.com/
|
||||
|
||||
# 2. Claude Code CLI (the cron does NOT auto-upgrade this; rerun this
|
||||
# line out-of-band when you want a fresh CLI to be tested)
|
||||
sudo npm install -g @anthropic-ai/claude-code@latest
|
||||
|
||||
# 3. Litellm checkout. Used by systemd's WorkingDirectory and as the
|
||||
# source of the .service / .timer files. The cron itself runs out
|
||||
# of the separate worktree at ~/litellm-cron-worktree/.
|
||||
mkdir -p ~/litellm
|
||||
git clone https://github.com/BerriAI/litellm.git ~/litellm/litellm
|
||||
git -C ~/litellm/litellm checkout litellm_internal_staging
|
||||
|
||||
# 4. gh auth — must be a collaborator on BerriAI/litellm-docs.
|
||||
gh auth login # follow prompts; pick HTTPS + token paste flow
|
||||
|
||||
# 5. Provider credentials + the publish token.
|
||||
sudo cp ~/litellm/litellm/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.env.example \
|
||||
/etc/litellm-compat-matrix.env
|
||||
sudoedit /etc/litellm-compat-matrix.env # fill in real values
|
||||
sudo chmod 0600 /etc/litellm-compat-matrix.env
|
||||
# The mateo-berri PAT lives in its own file, mapped into the service via
|
||||
# systemd LoadCredential so it stays out of the test processes' env
|
||||
# (see the env.example comment for why).
|
||||
sudo install -m 0600 /dev/null /etc/litellm-compat-matrix-github-token
|
||||
sudoedit /etc/litellm-compat-matrix-github-token # single line: the PAT
|
||||
|
||||
# 6. systemd units.
|
||||
sudo cp ~/litellm/litellm/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.service /etc/systemd/system/
|
||||
sudo cp ~/litellm/litellm/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now litellm-compat-matrix.timer
|
||||
```
|
||||
|
||||
## Operating it
|
||||
|
||||
```bash
|
||||
# When does it run next?
|
||||
systemctl list-timers litellm-compat-matrix.timer
|
||||
|
||||
# Trigger a real run right now (PRs to litellm-docs).
|
||||
sudo systemctl start litellm-compat-matrix.service
|
||||
|
||||
# Trigger a run that does NOT open a PR (good for first-time validation).
|
||||
SKIP_PUBLISH=1 ~/litellm/litellm/tests/e2e/claude_code/cron_vm/run_daily.sh
|
||||
|
||||
# Narrow to one cell while debugging.
|
||||
SKIP_PUBLISH=1 PYTEST_K='basic_messaging_non_streaming and anthropic' \
|
||||
~/litellm/litellm/tests/e2e/claude_code/cron_vm/run_daily.sh
|
||||
|
||||
# Watch the most recent run.
|
||||
journalctl -u litellm-compat-matrix.service -f
|
||||
|
||||
# Read older runs.
|
||||
journalctl -u litellm-compat-matrix.service --since '2 days ago'
|
||||
|
||||
# Disable until further notice (e.g. while debugging).
|
||||
sudo systemctl disable --now litellm-compat-matrix.timer
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **The venv is pinned to Python 3.12 (`CRON_PYTHON_VERSION`).** The
|
||||
e2e suite uses PEP 695 `type` aliases, which the VM's system Python
|
||||
(3.11) can't parse; `run_daily.sh` has uv fetch a managed CPython
|
||||
into `~/litellm-cron-worktree/.uv-python/` and syncs the venv against
|
||||
it. The first run after a version bump is a cold venv rebuild.
|
||||
- **The proxy port is `4100`, not `4000`.** This is so a developer SSH'd
|
||||
into the same VM with their own `:4000` proxy doesn't collide with a
|
||||
cron run. Override with `PROXY_PORT=...` in `/etc/litellm-compat-matrix.env`
|
||||
if you need to.
|
||||
- **`uv sync --frozen` requires the resolved tag to be tagged on
|
||||
GitHub.** If the latest stable release was made but not pushed as a
|
||||
git tag, the `git checkout` step fails. Push the tag, then rerun.
|
||||
- **Publish-token rotation is your problem.** The cron does not
|
||||
refresh the token; if `mateo-berri`'s PAT in
|
||||
`/etc/litellm-compat-matrix-github-token` expires, the run fails at
|
||||
the `git push`/`gh pr create` step with a 401 ("Bad credentials" /
|
||||
"Authentication failed"). Mint a fresh PAT and update that file.
|
||||
The token needs write access to `BerriAI/litellm-docs` (classic
|
||||
`repo` scope, or fine-grained Contents:RW + Pull requests:RW). It is
|
||||
delivered via systemd `LoadCredential`, not the env file, so pytest,
|
||||
the proxy, and the claude CLI never inherit it; manual runs export
|
||||
`GITHUB_TOKEN` instead.
|
||||
- **First run after upgrading the Claude Code CLI is the riskiest one.**
|
||||
If the new CLI changes its wire format the matrix run can produce
|
||||
systematic failures. Always run with `SKIP_PUBLISH=1` after a CLI
|
||||
upgrade before letting the next scheduled fire happen.
|
||||
- **Disk:** the worktree's `.venv` is ~1.3 GB and the `.git` directory
|
||||
is ~1 GB. Plan for at least 5 GB free on the VM, otherwise
|
||||
`uv sync` will fail mid-run and leave you with a half-installed venv.
|
||||
52
tests/e2e/claude_code/cron_vm/build_matrix.py
Normal file
52
tests/e2e/claude_code/cron_vm/build_matrix.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Tiny CLI wrapper around `claude_code.matrix_builder.build_from_paths`.
|
||||
|
||||
Exists only so `run_daily.sh` can hand the version metadata + paths into
|
||||
the matrix builder without re-implementing it in bash. All real logic
|
||||
lives in `matrix_builder.py`.
|
||||
|
||||
The suite imports its own modules with `tests/e2e/` on sys.path (that is
|
||||
how pytest resolves them: `tests/e2e/` has no `__init__.py`, while
|
||||
`claude_code/` does), so this script bootstraps the same root — two
|
||||
levels up from this file — before importing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from claude_code.matrix_builder import (
|
||||
build_from_paths,
|
||||
) # noqa: E402 # needs the sys.path bootstrap above
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--results", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--litellm-version", required=True)
|
||||
parser.add_argument("--claude-code-version", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
generated_at = datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
build_from_paths(
|
||||
manifest_path=args.manifest,
|
||||
results_path=args.results,
|
||||
litellm_version=args.litellm_version,
|
||||
claude_code_version=args.claude_code_version,
|
||||
generated_at=generated_at,
|
||||
output_path=args.output,
|
||||
)
|
||||
print(f"wrote {args.output}") # noqa: T201 # CLI output read by run_daily.sh
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
80
tests/e2e/claude_code/cron_vm/check_regressions.py
Normal file
80
tests/e2e/claude_code/cron_vm/check_regressions.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""CLI: detect green→red regressions between the published matrix and a
|
||||
freshly built one, so `run_daily.sh` can decide whether to enable
|
||||
auto-merge on the daily docs PR.
|
||||
|
||||
All real logic lives in `claude_code.matrix_builder.find_regressions`;
|
||||
this file only does the I/O and maps the result onto an exit code the
|
||||
bash caller can branch on.
|
||||
|
||||
Exit codes (the bash gate depends on these exact values):
|
||||
|
||||
0 no green→red regressions -> safe to auto-merge
|
||||
3 one or more green→red regressions -> do NOT auto-merge (human review)
|
||||
2 argparse/usage error (argparse default)
|
||||
|
||||
The `--old` file is allowed to be missing: on the first-ever publish there
|
||||
is no baseline to regress against, so we exit 0.
|
||||
|
||||
Imports resolve with `tests/e2e/` on sys.path, mirroring build_matrix.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from claude_code.matrix_builder import (
|
||||
find_regressions,
|
||||
) # noqa: E402 # needs the sys.path bootstrap above
|
||||
|
||||
REGRESSION_EXIT = 3
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--old",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="currently published matrix JSON (may be absent on first publish)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--new",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="freshly built matrix JSON",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.old.exists():
|
||||
print( # noqa: T201 # CLI output read by run_daily.sh
|
||||
"no published matrix to compare against "
|
||||
"(first publish); treating as no regressions"
|
||||
)
|
||||
return 0
|
||||
|
||||
old_matrix = json.loads(args.old.read_text())
|
||||
new_matrix = json.loads(args.new.read_text())
|
||||
|
||||
regressions = find_regressions(old_matrix, new_matrix)
|
||||
if not regressions:
|
||||
print("no green->red regressions detected") # noqa: T201 # CLI output
|
||||
return 0
|
||||
|
||||
print( # noqa: T201 # CLI output read by run_daily.sh
|
||||
f"detected {len(regressions)} green->red regression(s):"
|
||||
)
|
||||
for r in regressions:
|
||||
line = f" - {r['feature_name']} [{r['provider']}]: pass -> fail"
|
||||
if r["error"]:
|
||||
line += f" ({r['error'][:160]})"
|
||||
print(line) # noqa: T201 # CLI output read by run_daily.sh
|
||||
return REGRESSION_EXIT
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Environment file consumed by `litellm-compat-matrix.service`.
|
||||
#
|
||||
# Install at `/etc/litellm-compat-matrix.env` and chmod 0600.
|
||||
# `EnvironmentFile=-` in the unit means the service is allowed to start
|
||||
# even if this file is missing, but the populator will fail at the
|
||||
# first provider request without these credentials.
|
||||
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Bedrock (invoke + converse columns; also bedrock_mantle when enabled).
|
||||
# Use Anthropic's Bedrock API-key passthrough (long-lived bearer token).
|
||||
# No AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY required for the matrix --
|
||||
# both the LiteLLM invoke and converse routes pick up
|
||||
# AWS_BEARER_TOKEN_BEDROCK when present.
|
||||
AWS_BEARER_TOKEN_BEDROCK=
|
||||
AWS_REGION_NAME=us-east-1
|
||||
|
||||
# Vertex AI (vertex_ai + vertex_ai_gpt columns).
|
||||
# On the GCP VM, the default service-account ADC from the metadata server
|
||||
# is used -- no JSON key file is needed. If you ever need to run outside
|
||||
# GCP, also export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json.
|
||||
VERTEXAI_PROJECT=
|
||||
VERTEXAI_LOCATION=global
|
||||
|
||||
# Azure AI Foundry (azure column — Claude models on Foundry)
|
||||
AZURE_AI_API_KEY=
|
||||
AZURE_AI_API_BASE=
|
||||
|
||||
# OpenAI (openai GPT column)
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Azure OpenAI (azure_openai GPT column)
|
||||
AZURE_API_BASE=
|
||||
AZURE_API_KEY=
|
||||
|
||||
# The publish PAT (mateo-berri, write access on BerriAI/litellm-docs)
|
||||
# deliberately does NOT live in this file. Everything here lands in the
|
||||
# process environment of pytest, the proxy, and the model-driven claude
|
||||
# CLI, where any same-UID reader can lift it from /proc/<pid>/environ.
|
||||
# Instead, install the token at /etc/litellm-compat-matrix-github-token
|
||||
# (chmod 0600, single line); the service maps it in via systemd
|
||||
# LoadCredential and run_daily.sh keeps it out of every child process
|
||||
# env. Used to (a) resolve the latest stable release, (b) push the
|
||||
# daily compat-matrix branch directly to BerriAI/litellm-docs, (c) open
|
||||
# the same-repo PR, and (d) enable squash auto-merge on it. Scopes:
|
||||
# classic `repo` + `workflow`, or fine-grained on BerriAI/litellm-docs
|
||||
# with Contents:RW + Pull requests:RW + Workflows:RW.
|
||||
# Manual runs export GITHUB_TOKEN instead, or skip publishing entirely
|
||||
# with SKIP_PUBLISH=1 (only writes the matrix JSON locally).
|
||||
|
||||
# Optional: the bedrock_mantle column is opt-in because the AWS account
|
||||
# needs the Mantle (OpenAI-on-Bedrock) models enabled. Without this the
|
||||
# mantle cells are skipped and recorded as not_tested rather than fail.
|
||||
# COMPAT_MANTLE_CELLS=1
|
||||
|
||||
# Optional: the openai column is likewise opt-in; its cells hit CLI
|
||||
# timeouts under the concurrent stage suite, but the serial cron can
|
||||
# usually run them. Skipped cells are recorded as not_tested.
|
||||
# COMPAT_OPENAI_GPT_CELLS=1
|
||||
|
||||
# Optional overrides; defaults are sensible for the cron VM.
|
||||
# PROXY_PORT=4100
|
||||
# LITELLM_WORKTREE=/home/mateo/litellm-cron-worktree
|
||||
# DOCS_REPO=BerriAI/litellm-docs
|
||||
# DOCS_BRANCH=main
|
||||
# DOCS_TARGET_PATH=src/data/compatibility-matrix.json
|
||||
# AUTO_MERGE_METHOD=squash
|
||||
113
tests/e2e/claude_code/cron_vm/litellm-compat-matrix.service
Normal file
113
tests/e2e/claude_code/cron_vm/litellm-compat-matrix.service
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# systemd service for the Claude Code compatibility-matrix populator.
|
||||
#
|
||||
# Triggered by `litellm-compat-matrix.timer`; not started directly. The
|
||||
# unit is a `Type=oneshot` so the timer's `OnCalendar=` semantics
|
||||
# describe "run once per day" cleanly — there's no long-lived daemon to
|
||||
# supervise; each invocation runs the populator end-to-end and exits.
|
||||
#
|
||||
# Install
|
||||
# -------
|
||||
#
|
||||
# sudo cp tests/e2e/claude_code/cron_vm/litellm-compat-matrix.service /etc/systemd/system/
|
||||
# sudo cp tests/e2e/claude_code/cron_vm/litellm-compat-matrix.timer /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now litellm-compat-matrix.timer
|
||||
#
|
||||
# Paths are hard-coded to /home/mateo rather than using systemd's %h
|
||||
# specifier. Why: in *system* units (this one), %h is expanded at
|
||||
# parse time against the *manager's* home -- which is /root for PID 1
|
||||
# -- and *not* against the User= directive. That mismatch makes
|
||||
# ReadWritePaths point at /root/.cache (which doesn't exist), causing
|
||||
# the namespace setup to fail with status=226/NAMESPACE before the
|
||||
# script ever runs. The runtime user (`User=mateo`) must:
|
||||
#
|
||||
# * have a checkout of `BerriAI/litellm` at `~/litellm/litellm` so the
|
||||
# publisher module is importable;
|
||||
# * have a uv venv at `~/litellm/litellm/.venv` (created by
|
||||
# `uv sync --frozen` inside that checkout once);
|
||||
# * have `gh` already authenticated against an account with
|
||||
# `pull-requests: write` on `BerriAI/litellm-docs`;
|
||||
# * have provider credentials exported in `/etc/litellm-compat-matrix.env`
|
||||
# (see `litellm-compat-matrix.env.example` in this directory);
|
||||
# * have the mateo-berri publish PAT at
|
||||
# `/etc/litellm-compat-matrix-github-token` (chmod 0600, single
|
||||
# line), delivered via `LoadCredential=` below.
|
||||
|
||||
[Unit]
|
||||
Description=Claude Code compatibility-matrix populator (oneshot)
|
||||
Documentation=file:///home/mateo/litellm/litellm/tests/e2e/claude_code/cron_vm/README.md
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=mateo
|
||||
Group=mateo
|
||||
|
||||
# Provider credentials + any gh/PROXY_PORT overrides live here. Format
|
||||
# is the standard `KEY=value` one line per env var.
|
||||
EnvironmentFile=-/etc/litellm-compat-matrix.env
|
||||
|
||||
# The mateo-berri publish PAT is mapped in via the credential store, NOT
|
||||
# the EnvironmentFile, so it never lands in the process environment that
|
||||
# pytest, the proxy, and the model-driven claude CLI inherit (any
|
||||
# same-UID process can read /proc/<pid>/environ). run_daily.sh reads
|
||||
# ${CREDENTIALS_DIRECTORY}/github-token and hands it to gh per call.
|
||||
# Unlike EnvironmentFile= above, this is deliberately NOT optional: a
|
||||
# missing token file fails the unit at start instead of 30 minutes in.
|
||||
LoadCredential=github-token:/etc/litellm-compat-matrix-github-token
|
||||
|
||||
# systemd starts with a minimal PATH (~/usr/local/bin:/usr/bin:/bin).
|
||||
# `uv` and `claude` are installed under the runtime user's `~/.local/bin`
|
||||
# so we have to prepend it explicitly; otherwise run_daily.sh fails at
|
||||
# the up-front command-presence check.
|
||||
Environment=PATH=/home/mateo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
# `HOME` is auto-set to /home/mateo when User=mateo is honored, but be
|
||||
# explicit so anything that reads $HOME (e.g. uv's cache lookup, the
|
||||
# claude CLI's per-session dir) sees the right value even if a future
|
||||
# refactor flips DynamicUser= or PrivateUsers= on.
|
||||
Environment=HOME=/home/mateo
|
||||
|
||||
WorkingDirectory=/home/mateo/litellm/litellm
|
||||
|
||||
ExecStart=/home/mateo/litellm/litellm/tests/e2e/claude_code/cron_vm/run_daily.sh
|
||||
|
||||
# 90 minutes is generous: cold runs do `git clone` + `uv sync` of a new
|
||||
# tag's lockfile, which can take a couple of minutes on a 2-vCPU VM,
|
||||
# plus the full feature x provider grid of pytest cells hitting several
|
||||
# cloud providers.
|
||||
TimeoutStartSec=90min
|
||||
|
||||
# A failed run shouldn't restart automatically — the next timer fire is
|
||||
# the right retry. Reruns of the same day's matrix are idempotent.
|
||||
Restart=no
|
||||
|
||||
# Security hardening: the populator only reads the litellm checkout and
|
||||
# the env-file; everything else it writes lives in either the worktree
|
||||
# (managed) or `/tmp` (cleaned up by tempfile).
|
||||
#
|
||||
# ReadWritePaths whitelist:
|
||||
# * litellm-cron-worktree - the long-lived stable-tag checkout +
|
||||
# its `.venv` (`uv sync` rewrites every
|
||||
# run) + `.uv-bin` (pinned `uv` binary
|
||||
# cache).
|
||||
# * .cache - uv's wheel cache (~/.cache/uv) so we
|
||||
# don't redownload pinned deps each run.
|
||||
# * .claude - `claude` CLI's per-session state under
|
||||
# `~/.claude/projects/<sha>/`; created
|
||||
# on every `claude --print` invocation.
|
||||
# * .config/gh - `gh` CLI host config; technically not
|
||||
# needed when we pass GH_TOKEN inline,
|
||||
# but cheap to whitelist and prevents
|
||||
# future regressions if a code path
|
||||
# ever falls back to the host config.
|
||||
# * /tmp - mktemp -d workdir + proxy logs.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/mateo/litellm-cron-worktree /home/mateo/.cache /home/mateo/.claude /home/mateo/.config/gh /tmp
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
25
tests/e2e/claude_code/cron_vm/litellm-compat-matrix.timer
Normal file
25
tests/e2e/claude_code/cron_vm/litellm-compat-matrix.timer
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Daily timer for the compatibility-matrix populator.
|
||||
#
|
||||
# 06:00 UTC matches the original GitHub Actions cron schedule; chosen so
|
||||
# operators in US/EU timezones see fresh PRs at the start of their work
|
||||
# day.
|
||||
#
|
||||
# `Persistent=true` causes a missed run (VM was off / suspended) to
|
||||
# fire the next time the timer is started, which is the property we
|
||||
# want for a once-a-day job: the matrix should refresh as soon as the
|
||||
# VM is reachable again, not wait another 24h.
|
||||
#
|
||||
# `RandomizedDelaySec=10min` smears load if multiple matrix-style
|
||||
# pipelines are ever colocated on the same VM in the future.
|
||||
|
||||
[Unit]
|
||||
Description=Run the Claude Code compatibility-matrix populator daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 06:00:00 UTC
|
||||
Persistent=true
|
||||
RandomizedDelaySec=10min
|
||||
Unit=litellm-compat-matrix.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
672
tests/e2e/claude_code/cron_vm/run_daily.sh
Executable file
672
tests/e2e/claude_code/cron_vm/run_daily.sh
Executable file
|
|
@ -0,0 +1,672 @@
|
|||
#!/usr/bin/env bash
|
||||
# Daily Claude Code compatibility-matrix populator.
|
||||
#
|
||||
# Runs from the GCP VM `litellm-compatibility-matrix-populator` via the
|
||||
# systemd timer in this directory. The flow is:
|
||||
#
|
||||
# 1. Resolve the latest LiteLLM final release tag from the GitHub
|
||||
# Releases API.
|
||||
# 2. Update a long-lived worktree at $WORKTREE to that tag and `uv sync` it.
|
||||
# 3. Boot the proxy as a background subprocess on $PROXY_PORT (default
|
||||
# 4100; a separate port from the human-tended :4000 proxy).
|
||||
# 4. Run `pytest tests/e2e/claude_code/` against the proxy. Test
|
||||
# failures become `fail` cells in the JSON, not script errors.
|
||||
# 5. Hand the per-test results artifact + manifest to a small Python
|
||||
# CLI (`build_matrix.py`) that wraps the existing
|
||||
# `matrix_builder.build_from_paths` to produce the published
|
||||
# compatibility-matrix.json.
|
||||
# 6. `gh repo clone` litellm-docs, write the JSON to a deterministic
|
||||
# branch (`compat-matrix/<litellm>-<claude>-<UTC-date>`), commit,
|
||||
# push the branch straight to BerriAI/litellm-docs (mateo-berri has
|
||||
# write access), `gh pr create`, then — *only if no cell regressed
|
||||
# green→red versus the currently-published matrix* — enable squash
|
||||
# auto-merge so the PR merges itself once required checks pass. A
|
||||
# green→red regression leaves auto-merge off for human review; an
|
||||
# already-red cell (red→red) does not block.
|
||||
# 7. Sweep stale compat-matrix PRs: once today's PR exists, close any
|
||||
# other open `compat-matrix/*` PR (and delete its bot-owned branch)
|
||||
# so at most ONE compat-matrix PR is ever open — the newest. A
|
||||
# gate-withheld PR that nobody triages is superseded by the next
|
||||
# day's run rather than accumulating in the queue.
|
||||
#
|
||||
# Same-day reruns land on the same branch so they update the existing PR
|
||||
# rather than spawning a new one. If the JSON is byte-identical to the
|
||||
# docs branch, we skip the push entirely.
|
||||
#
|
||||
# Required commands on $PATH: git, uv, gh, jq, curl, claude, npm.
|
||||
# Required state: a litellm checkout at $LITELLM_REPO (this file lives in
|
||||
# it), $WORKTREE is created on first run, gh is already authenticated.
|
||||
#
|
||||
# Override any default by setting the matching env var; see the systemd
|
||||
# unit for the production wiring.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
LITELLM_REPO="${LITELLM_REPO:-${HOME}/litellm/litellm}"
|
||||
WORKTREE="${LITELLM_WORKTREE:-${HOME}/litellm-cron-worktree}"
|
||||
PROXY_PORT="${PROXY_PORT:-4100}"
|
||||
PROXY_API_KEY="${PROXY_API_KEY:-sk-cron-matrix}"
|
||||
DOCS_REPO="${DOCS_REPO:-BerriAI/litellm-docs}"
|
||||
DOCS_BRANCH="${DOCS_BRANCH:-main}"
|
||||
DOCS_TARGET_PATH="${DOCS_TARGET_PATH:-src/data/compatibility-matrix.json}"
|
||||
SKIP_PUBLISH="${SKIP_PUBLISH:-0}"
|
||||
PYTEST_K="${PYTEST_K:-}"
|
||||
# The e2e suite uses PEP 695 `type` aliases, so the venv needs Python
|
||||
# >= 3.12 (also what repo CI runs) even when the VM's system python is
|
||||
# older. uv fetches a managed CPython of this version on first use --
|
||||
# checksum-verified against the manifest baked into the pinned uv
|
||||
# binary -- and installs it under ${WORKTREE}/.uv-python (see
|
||||
# UV_PYTHON_INSTALL_DIR below) so it lives inside the one tree the
|
||||
# systemd sandbox lets us write to.
|
||||
CRON_PYTHON_VERSION="${CRON_PYTHON_VERSION:-3.12}"
|
||||
# Merge method for auto-merge. BerriAI/litellm-docs only allows squash
|
||||
# merges (merge-commit and rebase are disabled at the repo level), so
|
||||
# `squash` is the only valid value here unless that changes upstream.
|
||||
AUTO_MERGE_METHOD="${AUTO_MERGE_METHOD:-squash}"
|
||||
|
||||
POPULATOR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKDIR="$(mktemp -d -t litellm-compat-matrix.XXXXXX)"
|
||||
PROXY_PID_FILE="${WORKDIR}/proxy.pid"
|
||||
|
||||
# Cleanup is intentionally aggressive: it can run on normal exit, on a
|
||||
# signal received by the script, or after a partial failure where the
|
||||
# proxy is up but ${PROXY_PID_FILE} is stale. We try four things in
|
||||
# order and stop as soon as the proxy port is free:
|
||||
#
|
||||
# 1. SIGTERM the pid recorded in proxy.pid.
|
||||
# 2. SIGKILL anything from `pgrep -f "litellm.*--port ${PROXY_PORT}"`
|
||||
# that survived. This catches the common case where the recorded
|
||||
# pid was the sh wrapper, not the long-lived python child.
|
||||
# 3. ss -K on the port (kernel kills sockets but not processes;
|
||||
# mostly useful for catching lingering CLOSE_WAITs).
|
||||
# 4. wipe ${WORKDIR}.
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
set +e
|
||||
local proxy_pid
|
||||
if [[ -f "${PROXY_PID_FILE}" ]]; then
|
||||
proxy_pid="$(cat "${PROXY_PID_FILE}")"
|
||||
if [[ -n "${proxy_pid}" ]]; then
|
||||
kill -TERM "-${proxy_pid}" 2>/dev/null || kill -TERM "${proxy_pid}" 2>/dev/null || true
|
||||
for _ in 1 2 3 4 5; do
|
||||
kill -0 "${proxy_pid}" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
fi
|
||||
# Belt-and-braces: any python or uv talking to ${PROXY_PORT} that
|
||||
# survived the SIGTERM gets SIGKILL'd by name.
|
||||
pgrep -f "litellm.*--port[ =]?${PROXY_PORT}([^0-9]|$)" 2>/dev/null \
|
||||
| xargs -r kill -KILL 2>/dev/null || true
|
||||
pgrep -f "${WORKTREE}/.uv-bin/uv.*run litellm" 2>/dev/null \
|
||||
| xargs -r kill -KILL 2>/dev/null || true
|
||||
rm -rf "${WORKDIR}"
|
||||
exit "${rc}"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
log() { printf '==> %s\n' "$*" >&2; }
|
||||
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
for cmd in git uv gh jq curl claude; do
|
||||
command -v "${cmd}" >/dev/null 2>&1 || die "missing required command: ${cmd}"
|
||||
done
|
||||
|
||||
# Publishing pushes the branch straight to BerriAI/litellm-docs and opens
|
||||
# the PR as mateo-berri, who has write access on the docs repo. Under
|
||||
# systemd the PAT arrives as a file via LoadCredential=, NOT via the
|
||||
# EnvironmentFile: several suite cells let the model-driven claude CLI
|
||||
# read arbitrary files as this user, and /proc/<pid>/environ of the
|
||||
# script, pytest, and the proxy would hand an env-borne token to any
|
||||
# same-UID reader. Kept as an unexported shell variable and passed per
|
||||
# invocation (GH_TOKEN=... / curl header / push URL), it never enters a
|
||||
# child's environment. Manual runs may export GITHUB_TOKEN instead.
|
||||
# Require it up front -- failing 30 minutes into a run is a waste of CI
|
||||
# quota.
|
||||
if [[ -z "${GITHUB_TOKEN:-}" && -n "${CREDENTIALS_DIRECTORY:-}" && -f "${CREDENTIALS_DIRECTORY}/github-token" ]]; then
|
||||
GITHUB_TOKEN="$(<"${CREDENTIALS_DIRECTORY}/github-token")"
|
||||
log "publish token source: systemd credential store"
|
||||
elif [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||||
log "publish token source: process environment"
|
||||
fi
|
||||
if [[ "${SKIP_PUBLISH}" != "1" ]]; then
|
||||
[[ -n "${GITHUB_TOKEN:-}" ]] \
|
||||
|| die "publish token required: /etc/litellm-compat-matrix-github-token via LoadCredential under systemd, or an exported GITHUB_TOKEN for manual runs (or set SKIP_PUBLISH=1)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Resolve versions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Newest PEP 440 *final* release on BerriAI/litellm. LiteLLM moved off
|
||||
# the legacy `vX.Y.Z-stable` tag convention to PEP 440: a final/stable
|
||||
# release is now a bare `vX.Y.Z` tag, while pre-releases carry a
|
||||
# `-rc.N` / `-dev.N` segment (and the old `…-stable` / `…-stable.patch.N`
|
||||
# tags are legacy and frozen at v1.83.x). We therefore select the newest
|
||||
# tag with no pre-release segment -- matching `^v[0-9]+\.[0-9]+\.[0-9]+$`
|
||||
# -- and skip drafts. The numeric version_key sort handles 1.10 > 1.9.
|
||||
#
|
||||
# Paginate through the releases endpoint instead of grabbing only page 1
|
||||
# (default page_size=30). LiteLLM ships multiple pre-releases per day, so
|
||||
# it's common to need to walk past 30+ entries before hitting the most
|
||||
# recent final release. We cap at 5 pages (500 releases) which is
|
||||
# conservatively beyond the worst observed gap.
|
||||
GH_AUTH_HEADER=()
|
||||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||||
GH_AUTH_HEADER=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
|
||||
fi
|
||||
RELEASES_JSON="${WORKDIR}/releases.json"
|
||||
echo "[]" >"${RELEASES_JSON}"
|
||||
for page in 1 2 3 4 5; do
|
||||
PAGE_JSON="${WORKDIR}/releases.page${page}.json"
|
||||
curl -fsS \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'User-Agent: litellm-compat-matrix' \
|
||||
"${GH_AUTH_HEADER[@]}" \
|
||||
"https://api.github.com/repos/BerriAI/litellm/releases?per_page=100&page=${page}" \
|
||||
>"${PAGE_JSON}"
|
||||
jq -s '.[0] + .[1]' "${RELEASES_JSON}" "${PAGE_JSON}" >"${RELEASES_JSON}.merged"
|
||||
mv "${RELEASES_JSON}.merged" "${RELEASES_JSON}"
|
||||
# Stop early once we've seen at least one final release tag — no point
|
||||
# paging further for a daily script that only needs the newest.
|
||||
if jq -e '[.[] | select((.draft // false) == false) | .tag_name // "" | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))] | length > 0' "${PAGE_JSON}" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
# No more pages? GitHub returns an empty array past the last page.
|
||||
if [[ "$(jq 'length' "${PAGE_JSON}")" == "0" ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
LITELLM_VERSION="$(
|
||||
jq -r '
|
||||
[ .[]
|
||||
| select((.draft // false) == false)
|
||||
| .tag_name // empty
|
||||
| select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))
|
||||
]
|
||||
| sort_by(
|
||||
capture("^v(?<a>[0-9]+)\\.(?<b>[0-9]+)\\.(?<c>[0-9]+)$")
|
||||
| [(.a|tonumber), (.b|tonumber), (.c|tonumber)]
|
||||
)
|
||||
| last // empty
|
||||
' "${RELEASES_JSON}"
|
||||
)"
|
||||
[[ -n "${LITELLM_VERSION}" ]] || die "could not resolve latest PEP 440 final release (vX.Y.Z) in 5 pages of releases"
|
||||
log "resolved litellm: ${LITELLM_VERSION}"
|
||||
|
||||
CLAUDE_CODE_VERSION="$(claude --version 2>/dev/null | awk '{print $1}')"
|
||||
[[ -n "${CLAUDE_CODE_VERSION}" ]] || die "could not read 'claude --version'"
|
||||
log "local claude code: ${CLAUDE_CODE_VERSION}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Update the worktree to that tag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [[ ! -d "${WORKTREE}/.git" ]]; then
|
||||
log "first run: cloning litellm into ${WORKTREE}"
|
||||
mkdir -p "$(dirname "${WORKTREE}")"
|
||||
git clone https://github.com/BerriAI/litellm.git "${WORKTREE}"
|
||||
fi
|
||||
|
||||
log "updating worktree to ${LITELLM_VERSION}"
|
||||
git -C "${WORKTREE}" fetch --tags --force
|
||||
git -C "${WORKTREE}" reset --hard
|
||||
# Keep the venv, the .uv-bin cache, and the .uv-python managed
|
||||
# interpreter around — uv sync will reconcile the venv on every run,
|
||||
# and we don't want to re-download the pinned uv binary or the managed
|
||||
# CPython each time. Drop everything else (including any prior
|
||||
# tests/e2e/ shim) so each run starts clean before the shim below
|
||||
# rewrites it from the dev checkout.
|
||||
git -C "${WORKTREE}" clean -fdx -e .venv -e .uv-bin -e .uv-python
|
||||
git -C "${WORKTREE}" checkout --force "${LITELLM_VERSION}"
|
||||
|
||||
# Always rebuild tests/e2e/ in the worktree from the dev checkout,
|
||||
# regardless of what the resolved ${LITELLM_VERSION} tag ships. Two
|
||||
# reasons:
|
||||
#
|
||||
# * The matrix populator's job is to exercise *today's* tests against
|
||||
# the latest stable proxy. The dev checkout carries the most recent
|
||||
# test fixes that haven't yet rolled into a stable release, and we
|
||||
# want every cron run to pick those up the moment they land on
|
||||
# ${LITELLM_REPO}, not whenever the next stable release happens.
|
||||
# * The tag's own tests/e2e/ ships the full EKS e2e harness, whose
|
||||
# top-level conftest.py imports modules (e2e_db, lifecycle,
|
||||
# otel_client, ...) that the stable venv does not install. Copying
|
||||
# the whole tree would make pytest collection blow up on those
|
||||
# imports.
|
||||
#
|
||||
# So the shim is a fresh `rm -rf` of tests/e2e/ followed by copying ONLY
|
||||
# the claude_code suite plus the shared transport helpers it imports.
|
||||
# pytest puts tests/e2e/ itself on sys.path (it has no __init__.py, while
|
||||
# claude_code/ does), which is what resolves both the `claude_code.*`
|
||||
# and the bare `proxy_client` / `e2e_http` imports inside the suite.
|
||||
E2E_HELPER_FILES=(proxy_client.py e2e_http.py models.py e2e_config.py transport.py)
|
||||
if [[ ! -d "${LITELLM_REPO}/tests/e2e/claude_code" ]]; then
|
||||
die "no shim source at ${LITELLM_REPO}/tests/e2e/claude_code"
|
||||
fi
|
||||
for helper in "${E2E_HELPER_FILES[@]}"; do
|
||||
[[ -f "${LITELLM_REPO}/tests/e2e/${helper}" ]] \
|
||||
|| die "missing shim helper: ${LITELLM_REPO}/tests/e2e/${helper}"
|
||||
done
|
||||
log "shimming tests/e2e/claude_code/ + helpers from ${LITELLM_REPO} (always-overwrite)"
|
||||
rm -rf "${WORKTREE}/tests/e2e"
|
||||
mkdir -p "${WORKTREE}/tests/e2e"
|
||||
cp -r "${LITELLM_REPO}/tests/e2e/claude_code" "${WORKTREE}/tests/e2e/"
|
||||
for helper in "${E2E_HELPER_FILES[@]}"; do
|
||||
cp "${LITELLM_REPO}/tests/e2e/${helper}" "${WORKTREE}/tests/e2e/"
|
||||
done
|
||||
|
||||
# litellm pins an exact uv version in pyproject.toml's [tool.uv]
|
||||
# `required-version` field, so a system uv that's newer or older
|
||||
# refuses to sync. We pin our own local copy at the version the
|
||||
# checked-out tag asks for, cached under .uv-bin/ inside the worktree
|
||||
# so subsequent runs skip the download.
|
||||
PINNED_UV_VERSION="$(
|
||||
awk -F'"' '
|
||||
/^required-version[[:space:]]*=/ {
|
||||
# Field 2 is the value between the quotes, e.g. ">=0.10.9" or
|
||||
# "0.10.9". Strip any leading specifier prefix so we end up with
|
||||
# the bare version string, which is what /releases/download/<v>/
|
||||
# expects.
|
||||
v = $2
|
||||
sub(/^[[:space:]=<>!~]+/, "", v)
|
||||
if (v != "") { print v; exit }
|
||||
}
|
||||
' "${WORKTREE}/pyproject.toml"
|
||||
)"
|
||||
if [[ -z "${PINNED_UV_VERSION}" ]]; then
|
||||
log "no uv version pin in pyproject.toml; using system uv"
|
||||
WORKTREE_UV="$(command -v uv)"
|
||||
else
|
||||
WORKTREE_UV="${WORKTREE}/.uv-bin/uv-${PINNED_UV_VERSION}"
|
||||
if [[ ! -x "${WORKTREE_UV}" ]]; then
|
||||
log "downloading uv ${PINNED_UV_VERSION} for the worktree"
|
||||
mkdir -p "${WORKTREE}/.uv-bin"
|
||||
UV_TARBALL_NAME="uv-x86_64-unknown-linux-gnu.tar.gz"
|
||||
UV_DOWNLOAD_URL="https://github.com/astral-sh/uv/releases/download/${PINNED_UV_VERSION}/${UV_TARBALL_NAME}"
|
||||
UV_TMPDIR="$(mktemp -d -t uv-download.XXXXXX)"
|
||||
# Download the tarball and Astral's official .sha256 sidecar to disk
|
||||
# and verify the digest before extracting/executing anything. This
|
||||
# closes the supply-chain trust gap of piping a remote binary
|
||||
# straight into `tar -xzO ... > file ; chmod +x` (see CLAUDE.md
|
||||
# "CI Supply-Chain Safety").
|
||||
curl -fsSL --output "${UV_TMPDIR}/${UV_TARBALL_NAME}" "${UV_DOWNLOAD_URL}"
|
||||
curl -fsSL --output "${UV_TMPDIR}/${UV_TARBALL_NAME}.sha256" "${UV_DOWNLOAD_URL}.sha256"
|
||||
(cd "${UV_TMPDIR}" && sha256sum -c "${UV_TARBALL_NAME}.sha256") \
|
||||
|| { rm -rf "${UV_TMPDIR}"; die "uv ${PINNED_UV_VERSION} sha256 mismatch — refusing to install"; }
|
||||
tar -xzf "${UV_TMPDIR}/${UV_TARBALL_NAME}" -C "${UV_TMPDIR}" "uv-x86_64-unknown-linux-gnu/uv"
|
||||
mv "${UV_TMPDIR}/uv-x86_64-unknown-linux-gnu/uv" "${WORKTREE_UV}.tmp"
|
||||
chmod +x "${WORKTREE_UV}.tmp"
|
||||
mv "${WORKTREE_UV}.tmp" "${WORKTREE_UV}"
|
||||
rm -rf "${UV_TMPDIR}"
|
||||
fi
|
||||
fi
|
||||
# `--extra proxy` pulls fastapi/uvicorn/etc. so `uv run litellm` can
|
||||
# actually serve. `--group proxy-dev` brings in pytest and the rest of
|
||||
# what tests/e2e/claude_code/ needs. `--python` pins the venv to
|
||||
# ${CRON_PYTHON_VERSION}; the first run after a version bump recreates
|
||||
# the venv from scratch (a one-time cold sync).
|
||||
export UV_PYTHON_INSTALL_DIR="${WORKTREE}/.uv-python"
|
||||
log "uv sync --frozen --group proxy-dev --extra proxy --python ${CRON_PYTHON_VERSION} (uv ${PINNED_UV_VERSION:-system})"
|
||||
(cd "${WORKTREE}" && "${WORKTREE_UV}" sync --frozen --group proxy-dev --extra proxy --python "${CRON_PYTHON_VERSION}")
|
||||
|
||||
PROXY_CONFIG="${WORKTREE}/tests/e2e/claude_code/test_config.yaml"
|
||||
[[ -f "${PROXY_CONFIG}" ]] || die "proxy config not found at ${PROXY_CONFIG} (shim incomplete?)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Boot the proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
log "starting proxy on 127.0.0.1:${PROXY_PORT}"
|
||||
# Bind the proxy to loopback only. The populator proxy is talked to
|
||||
# exclusively by the pytest run on the same host (the health check and
|
||||
# the test env set `LITELLM_PROXY_URL=http://127.0.0.1:...`),
|
||||
# so there's no reason to expose it on the VM's external interfaces.
|
||||
# Without `--host`, `litellm` defaults to 0.0.0.0, which combined with
|
||||
# the predictable default `LITELLM_MASTER_KEY=sk-cron-matrix` would
|
||||
# allow anything that can reach :${PROXY_PORT} on the VM to authenticate
|
||||
# and burn upstream provider credentials.
|
||||
#
|
||||
# `setsid` puts the proxy in its own session+pgroup so cleanup() can
|
||||
# SIGTERM the whole tree by passing the pgid as a negative pid. We
|
||||
# write that pid to a file so cleanup() doesn't need to remember a
|
||||
# variable that might be stale by the time the trap fires.
|
||||
setsid env LITELLM_MASTER_KEY="${PROXY_API_KEY}" bash -c '
|
||||
echo "$$" > "$0"
|
||||
cd "$1"
|
||||
exec "$2" run litellm --config "$3" --host 127.0.0.1 --port "$4"
|
||||
' "${PROXY_PID_FILE}" "${WORKTREE}" "${WORKTREE_UV}" "${PROXY_CONFIG}" "${PROXY_PORT}" \
|
||||
>"${WORKDIR}/proxy.log" 2>&1 &
|
||||
disown
|
||||
|
||||
HEALTH_URL="http://127.0.0.1:${PROXY_PORT}/health/liveliness"
|
||||
for _ in $(seq 1 45); do
|
||||
if curl -fsS "${HEALTH_URL}" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
curl -fsS "${HEALTH_URL}" >/dev/null \
|
||||
|| { tail -50 "${WORKDIR}/proxy.log" >&2; die "proxy did not become healthy"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Run pytest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RESULTS_JSON="${WORKDIR}/compat-results.json"
|
||||
# The `_*_unit_tests` ignore is defensive: those harness-only trees are
|
||||
# markerless (they run without a proxy) and don't feed matrix cells, so
|
||||
# the cron skips them if/when they land in the suite.
|
||||
PYTEST_ARGS=(
|
||||
tests/e2e/claude_code/
|
||||
"--ignore-glob=*_unit_tests*"
|
||||
)
|
||||
if [[ -n "${PYTEST_K}" ]]; then
|
||||
log "PYTEST_K set; narrowing to: ${PYTEST_K}"
|
||||
PYTEST_ARGS+=(-k "${PYTEST_K}")
|
||||
fi
|
||||
|
||||
log "running pytest"
|
||||
set +e
|
||||
(
|
||||
cd "${WORKTREE}" \
|
||||
&& LITELLM_PROXY_URL="http://127.0.0.1:${PROXY_PORT}" \
|
||||
LITELLM_MASTER_KEY="${PROXY_API_KEY}" \
|
||||
COMPAT_RESULTS_PATH="${RESULTS_JSON}" \
|
||||
"${WORKTREE_UV}" run pytest "${PYTEST_ARGS[@]}"
|
||||
)
|
||||
PYTEST_EXIT=$?
|
||||
set -e
|
||||
log "pytest exit code: ${PYTEST_EXIT} (failures become 'fail' cells, not script errors)"
|
||||
# 0=green, 1=test failures (fail cells); >=2 = interrupted/internal/usage/no
|
||||
# tests, i.e. a partial run whose missing cells would publish as not_tested.
|
||||
[[ ${PYTEST_EXIT} -le 1 ]] \
|
||||
|| die "pytest exited abnormally (${PYTEST_EXIT}); refusing to publish a partial matrix"
|
||||
[[ -f "${RESULTS_JSON}" ]] || die "pytest did not produce ${RESULTS_JSON}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Build the matrix JSON
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MATRIX_JSON="${WORKDIR}/compatibility-matrix.json"
|
||||
log "building ${MATRIX_JSON}"
|
||||
(
|
||||
cd "${WORKTREE}" \
|
||||
&& "${WORKTREE_UV}" run python "${POPULATOR_DIR}/build_matrix.py" \
|
||||
--manifest "${WORKTREE}/tests/e2e/claude_code/manifest.yaml" \
|
||||
--results "${RESULTS_JSON}" \
|
||||
--output "${MATRIX_JSON}" \
|
||||
--litellm-version "${LITELLM_VERSION}" \
|
||||
--claude-code-version "${CLAUDE_CODE_VERSION}"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Open a docs-repo PR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [[ "${SKIP_PUBLISH}" == "1" ]]; then
|
||||
cp "${MATRIX_JSON}" "${LITELLM_REPO}/compatibility-matrix.json"
|
||||
log "SKIP_PUBLISH=1; matrix written to ${LITELLM_REPO}/compatibility-matrix.json"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DATE_UTC="$(date -u +%Y-%m-%d)"
|
||||
BRANCH_NAME="compat-matrix/${LITELLM_VERSION}-${CLAUDE_CODE_VERSION}-${DATE_UTC}"
|
||||
DOCS_CLONE="${WORKDIR}/litellm-docs"
|
||||
|
||||
log "cloning ${DOCS_REPO}@${DOCS_BRANCH}"
|
||||
gh repo clone "${DOCS_REPO}" "${DOCS_CLONE}" -- --depth 1 --branch "${DOCS_BRANCH}"
|
||||
|
||||
cd "${DOCS_CLONE}"
|
||||
git config user.email "litellm-bot@berri.ai"
|
||||
git config user.name "litellm-compat-matrix-bot"
|
||||
git checkout -b "${BRANCH_NAME}"
|
||||
|
||||
# Snapshot the currently-published matrix *before* we overwrite it, so the
|
||||
# auto-merge gate below can diff old→new cell statuses. On the first-ever
|
||||
# publish the file won't exist yet; we leave ${PUBLISHED_MATRIX} pointing
|
||||
# at a path that doesn't exist and let check_regressions.py treat that as
|
||||
# "no baseline → no regressions".
|
||||
PUBLISHED_MATRIX="${WORKDIR}/published-matrix.json"
|
||||
if [[ -f "${DOCS_TARGET_PATH}" ]]; then
|
||||
cp "${DOCS_TARGET_PATH}" "${PUBLISHED_MATRIX}"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${DOCS_TARGET_PATH}")"
|
||||
cp "${MATRIX_JSON}" "${DOCS_TARGET_PATH}"
|
||||
git add "${DOCS_TARGET_PATH}"
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
log "matrix JSON unchanged from ${DOCS_BRANCH}; skipping PR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Auto-merge regression gate --------------------------------------------
|
||||
# Only auto-merge when the new matrix is improvement-or-equal: every cell
|
||||
# transition is red→green, green→green, or red→red. If any cell flips
|
||||
# green→red (a `pass` that became `fail`), we still open/refresh the PR but
|
||||
# leave auto-merge OFF so a human reviews the regression before it lands on
|
||||
# the public docs table. A pre-existing red cell (e.g. Anthropic out of API
|
||||
# credits) is red→red and does NOT block, so the daily PR keeps flowing.
|
||||
log "checking for green->red regressions vs the published matrix"
|
||||
set +e
|
||||
REGRESSION_REPORT="$(
|
||||
cd "${WORKTREE}" \
|
||||
&& "${WORKTREE_UV}" run python "${POPULATOR_DIR}/check_regressions.py" \
|
||||
--old "${PUBLISHED_MATRIX}" \
|
||||
--new "${MATRIX_JSON}"
|
||||
)"
|
||||
REGRESSION_EXIT=$?
|
||||
set -e
|
||||
printf '%s\n' "${REGRESSION_REPORT}" | sed 's/^/ /' >&2
|
||||
# Exit 0 = clean. Exit 3 = green→red regression(s) found. Any other code
|
||||
# means the checker itself errored; fail *closed* (withhold auto-merge) so a
|
||||
# bug in the gate can never silently auto-merge a regression.
|
||||
if [[ ${REGRESSION_EXIT} -eq 0 ]]; then
|
||||
ALLOW_AUTOMERGE=1
|
||||
elif [[ ${REGRESSION_EXIT} -eq 3 ]]; then
|
||||
ALLOW_AUTOMERGE=0
|
||||
log "WARN: green->red regression(s) detected; auto-merge will be left OFF for review"
|
||||
else
|
||||
ALLOW_AUTOMERGE=0
|
||||
log "WARN: regression check errored (exit ${REGRESSION_EXIT}); withholding auto-merge to be safe"
|
||||
fi
|
||||
|
||||
GENERATED_AT="$(jq -r '.generated_at' "${MATRIX_JSON}")"
|
||||
COMMIT_MSG="$(cat <<EOF
|
||||
Update Claude Code compatibility matrix
|
||||
|
||||
litellm_version: ${LITELLM_VERSION}
|
||||
claude_code_version: ${CLAUDE_CODE_VERSION}
|
||||
generated_at: ${GENERATED_AT}
|
||||
EOF
|
||||
)"
|
||||
git commit -m "${COMMIT_MSG}"
|
||||
|
||||
# Push the branch straight to BerriAI/litellm-docs. mateo-berri has write
|
||||
# access on the docs repo, so there's no fork hop: the PR is a same-repo
|
||||
# branch PR. The temp remote carries the token in its URL, so we add it,
|
||||
# push, then immediately remove it so the token never lingers in
|
||||
# ${DOCS_CLONE}/.git/config. (${DOCS_CLONE} is also rm -rf'd by the
|
||||
# cleanup trap on exit.)
|
||||
#
|
||||
# Plain --force (not --force-with-lease) is acceptable here: the
|
||||
# compat-matrix/* branch is bot-owned, only this script ever writes to
|
||||
# it, and runs are serialized by the systemd timer. --force-with-lease
|
||||
# would require a fetch to populate the remote-tracking ref before each
|
||||
# push and adds no safety in this single-writer setup.
|
||||
PUBLISH_PUSH_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${DOCS_REPO}.git"
|
||||
git remote remove publish 2>/dev/null || true
|
||||
git remote add publish "${PUBLISH_PUSH_URL}"
|
||||
git push --force --set-upstream publish "${BRANCH_NAME}"
|
||||
git remote remove publish
|
||||
unset PUBLISH_PUSH_URL
|
||||
|
||||
# Per-feature status table for the PR body. Reviewers triage from this.
|
||||
PR_FEATURE_TABLE="$(jq -r '
|
||||
.features[] as $f
|
||||
| "- **\($f.name)**: " +
|
||||
([ .providers[] as $p
|
||||
| "\($p)=\($f.providers[$p].status // "not_tested")"
|
||||
] | join(", "))
|
||||
' "${MATRIX_JSON}")"
|
||||
|
||||
# When the gate withheld auto-merge, call it out at the top of the PR body
|
||||
# (with the offending cells) so a reviewer knows this PR needs a human and
|
||||
# why. On the clean path this section is empty. Note `$(...)` strips the
|
||||
# trailing newline, so the body below puts explicit blank lines *around*
|
||||
# the placeholder rather than relying on the heredoc's own spacing.
|
||||
if [[ "${ALLOW_AUTOMERGE}" != "1" ]]; then
|
||||
PR_REGRESSION_SECTION="$(cat <<EOF
|
||||
> [!WARNING]
|
||||
> **Auto-merge disabled:** one or more cells regressed green→red versus the
|
||||
> currently-published matrix. Review the diff before merging.
|
||||
|
||||
\`\`\`
|
||||
${REGRESSION_REPORT}
|
||||
\`\`\`
|
||||
EOF
|
||||
)"
|
||||
else
|
||||
PR_REGRESSION_SECTION=""
|
||||
fi
|
||||
|
||||
PR_TITLE="chore(compat-matrix): refresh for ${LITELLM_VERSION} + claude-code ${CLAUDE_CODE_VERSION}"
|
||||
PR_BODY="$(cat <<EOF
|
||||
Automated daily refresh of the Claude Code compatibility matrix.
|
||||
|
||||
${PR_REGRESSION_SECTION}
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| litellm_version | \`${LITELLM_VERSION}\` |
|
||||
| claude_code_version | \`${CLAUDE_CODE_VERSION}\` |
|
||||
| generated_at | \`${GENERATED_AT}\` |
|
||||
|
||||
## Per-feature results
|
||||
|
||||
${PR_FEATURE_TABLE}
|
||||
|
||||
---
|
||||
|
||||
Generated by \`tests/e2e/claude_code/cron_vm/run_daily.sh\`. Close without merging if the diff looks wrong; the next cron run will reopen with fresh results.
|
||||
EOF
|
||||
)"
|
||||
|
||||
log "opening PR from ${BRANCH_NAME} -> ${DOCS_REPO}:${DOCS_BRANCH} (as mateo-berri)"
|
||||
# GH_TOKEN is mateo-berri's write-scoped token, the same identity used
|
||||
# for release-listing above. The branch lives on ${DOCS_REPO} itself, so
|
||||
# --head is a bare branch name (a same-repo PR), not `OWNER:BRANCH`.
|
||||
set +e
|
||||
PR_OUT="$(
|
||||
GH_TOKEN="${GITHUB_TOKEN}" gh pr create \
|
||||
--repo "${DOCS_REPO}" \
|
||||
--base "${DOCS_BRANCH}" \
|
||||
--head "${BRANCH_NAME}" \
|
||||
--title "${PR_TITLE}" \
|
||||
--body "${PR_BODY}" 2>&1
|
||||
)"
|
||||
PR_EXIT=$?
|
||||
set -e
|
||||
echo "${PR_OUT}"
|
||||
|
||||
if [[ ${PR_EXIT} -ne 0 ]]; then
|
||||
if grep -q "a pull request for branch.*already exists" <<<"${PR_OUT}"; then
|
||||
log "PR already exists for ${BRANCH_NAME}; updated branch in place"
|
||||
else
|
||||
die "gh pr create failed (exit ${PR_EXIT})"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Enable auto-merge so the PR merges itself once the docs repo's required
|
||||
# checks pass -- we no longer gate these bot PRs on a second human
|
||||
# approval. mateo-berri authors and merges them directly. The repo only
|
||||
# permits squash merges and has auto-merge enabled at the repo level
|
||||
# (${AUTO_MERGE_METHOD} defaults to squash accordingly).
|
||||
#
|
||||
# This only fires when the regression gate above is satisfied
|
||||
# (${ALLOW_AUTOMERGE}==1): a green→red regression — or a gate error —
|
||||
# leaves auto-merge OFF so a human triages the PR.
|
||||
#
|
||||
# `gh pr merge --auto` is idempotent: re-enabling auto-merge on a PR that
|
||||
# already has it set is a no-op, so same-day reruns stay clean. It's
|
||||
# non-fatal: if auto-merge can't be enabled (e.g. the PR is already in a
|
||||
# clean/mergeable state with nothing left to wait on, or branch
|
||||
# protection isn't configured), the matrix JSON has still landed on the
|
||||
# PR and the worst case is a manual merge click.
|
||||
if [[ "${ALLOW_AUTOMERGE}" == "1" ]]; then
|
||||
log "enabling ${AUTO_MERGE_METHOD} auto-merge on ${BRANCH_NAME}"
|
||||
set +e
|
||||
GH_TOKEN="${GITHUB_TOKEN}" gh pr merge \
|
||||
"${BRANCH_NAME}" \
|
||||
--repo "${DOCS_REPO}" \
|
||||
--auto \
|
||||
"--${AUTO_MERGE_METHOD}" 2>&1 | sed 's/^/ /'
|
||||
AUTOMERGE_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [[ ${AUTOMERGE_EXIT} -ne 0 ]]; then
|
||||
log "WARN: gh pr merge --auto exited ${AUTOMERGE_EXIT} (non-fatal)"
|
||||
fi
|
||||
else
|
||||
# Regression (or gate error): make sure auto-merge is OFF. A same-day
|
||||
# rerun may have enabled it on an earlier, clean pass, so explicitly
|
||||
# disable rather than just skipping. The disable call itself is allowed
|
||||
# to error (`--disable-auto` fails harmlessly when auto-merge was never
|
||||
# enabled), but the read-back below is authoritative: a regressed matrix
|
||||
# must never be left armed to merge, so a still-armed PR is fatal.
|
||||
log "leaving ${BRANCH_NAME} for manual review; disabling any prior auto-merge"
|
||||
set +e
|
||||
GH_TOKEN="${GITHUB_TOKEN}" gh pr merge \
|
||||
"${BRANCH_NAME}" \
|
||||
--repo "${DOCS_REPO}" \
|
||||
--disable-auto 2>&1 | sed 's/^/ /'
|
||||
set -e
|
||||
AUTOMERGE_ARMED="$(
|
||||
GH_TOKEN="${GITHUB_TOKEN}" gh pr view \
|
||||
"${BRANCH_NAME}" \
|
||||
--repo "${DOCS_REPO}" \
|
||||
--json autoMergeRequest \
|
||||
--jq '.autoMergeRequest.enabledAt // empty'
|
||||
)" || die "could not read back the auto-merge state on ${BRANCH_NAME}"
|
||||
[[ -z "${AUTOMERGE_ARMED}" ]] \
|
||||
|| die "auto-merge still armed on ${BRANCH_NAME} (enabled ${AUTOMERGE_ARMED}) after --disable-auto"
|
||||
fi
|
||||
|
||||
# --- Stale-PR sweep ----------------------------------------------------------
|
||||
# Keep at most ONE compat-matrix PR open: today's. Any other open
|
||||
# `compat-matrix/*` PR is a leftover from a day whose regression gate
|
||||
# withheld auto-merge and nobody triaged it; the PR we just opened or
|
||||
# refreshed above carries strictly fresher results, so the old one is
|
||||
# pure queue noise. Closing is non-destructive — the PR record and its
|
||||
# regression report stay browsable; only the bot-owned branch is
|
||||
# deleted. This runs only after today's PR exists (a `die` above skips
|
||||
# it), so a failed publish can never close the queue down to zero.
|
||||
#
|
||||
# Non-fatal: a sweep failure (rate limit, transient API error) leaves
|
||||
# stale PRs for the next run to retry; it must not fail the pipeline.
|
||||
log "sweeping stale compat-matrix PRs (keeping ${BRANCH_NAME})"
|
||||
set +e
|
||||
STALE_PRS="$(
|
||||
GH_TOKEN="${GITHUB_TOKEN}" gh pr list \
|
||||
--repo "${DOCS_REPO}" \
|
||||
--state open \
|
||||
--limit 100 \
|
||||
--json number,headRefName \
|
||||
--jq '.[] | select(.headRefName | startswith("compat-matrix/")) | "\(.number)\t\(.headRefName)"'
|
||||
)"
|
||||
while IFS=$'\t' read -r stale_pr stale_head; do
|
||||
[[ -z "${stale_pr}" ]] && continue
|
||||
[[ "${stale_head}" == "${BRANCH_NAME}" ]] && continue
|
||||
GH_TOKEN="${GITHUB_TOKEN}" gh pr close "${stale_pr}" \
|
||||
--repo "${DOCS_REPO}" \
|
||||
--delete-branch \
|
||||
--comment "Superseded by the newer daily compat-matrix PR from \`${BRANCH_NAME}\`; the populator keeps only the most recent compat-matrix PR open." 2>&1 | sed 's/^/ /'
|
||||
if [[ ${PIPESTATUS[0]} -eq 0 ]]; then
|
||||
log "closed stale compat-matrix PR #${stale_pr} (${stale_head})"
|
||||
else
|
||||
log "WARN: could not close stale compat-matrix PR #${stale_pr} (non-fatal)"
|
||||
fi
|
||||
done <<<"${STALE_PRS}"
|
||||
set -e
|
||||
|
||||
log "done"
|
||||
|
|
@ -174,6 +174,86 @@ def _aggregate_cell(results: Sequence[Mapping[str, Any]]) -> Dict[str, Any]:
|
|||
return {"status": "not_tested"}
|
||||
|
||||
|
||||
def _index_cells(matrix: Mapping[str, Any]) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
"""Map ``(feature_id, provider) -> cell dict`` for a built matrix.
|
||||
|
||||
Cells are keyed by the *stable* feature ``id`` (not the display
|
||||
``name``, which can be reworded without changing the underlying row)
|
||||
and the provider key, so two matrices built at different times line up
|
||||
even if feature names drift.
|
||||
"""
|
||||
out: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for feature in matrix.get("features", []) or []:
|
||||
if not isinstance(feature, Mapping):
|
||||
continue
|
||||
feature_id = feature.get("id")
|
||||
if not feature_id:
|
||||
continue
|
||||
providers = feature.get("providers", {}) or {}
|
||||
if not isinstance(providers, Mapping):
|
||||
continue
|
||||
for provider, cell in providers.items():
|
||||
if isinstance(cell, Mapping):
|
||||
out[(feature_id, provider)] = dict(cell)
|
||||
return out
|
||||
|
||||
|
||||
def find_regressions(
|
||||
old_matrix: Mapping[str, Any],
|
||||
new_matrix: Mapping[str, Any],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return the cells that flipped green→red (``pass`` → ``fail``).
|
||||
|
||||
A *regression* is defined strictly: a cell that was ``pass`` in
|
||||
``old_matrix`` and is ``fail`` in ``new_matrix``. Every other
|
||||
transition is intentionally *not* a regression:
|
||||
|
||||
* ``red → green`` / ``green → green`` — the happy path.
|
||||
* ``red → red`` — a cell that is *already* failing for an unrelated
|
||||
reason (e.g. Anthropic out of API credits) must not block
|
||||
publishing, otherwise the daily PR would never auto-merge until
|
||||
that independent issue is fixed.
|
||||
* ``green → not_tested`` / ``green → not_applicable`` — a cell going
|
||||
grey is a degradation but not a *red* regression; treating a
|
||||
skipped/flaky run as a hard block would create false positives.
|
||||
|
||||
Cells present only in ``new_matrix`` (a newly added feature or
|
||||
provider) have no baseline and therefore cannot be regressions.
|
||||
|
||||
Each returned item is a flat str→str mapping so callers (the cron's
|
||||
``check_regressions.py``) can render it without further lookups:
|
||||
``feature_id``, ``feature_name``, ``provider``, ``old_status``,
|
||||
``new_status``, ``error``.
|
||||
"""
|
||||
old_cells = _index_cells(old_matrix)
|
||||
feature_names = {
|
||||
f.get("id"): str(f.get("name", f.get("id")))
|
||||
for f in new_matrix.get("features", []) or []
|
||||
if isinstance(f, Mapping) and f.get("id")
|
||||
}
|
||||
|
||||
regressions: list[dict[str, str]] = []
|
||||
for (feature_id, provider), new_cell in sorted(
|
||||
_index_cells(new_matrix).items(), key=lambda kv: (kv[0][0], kv[0][1])
|
||||
):
|
||||
if new_cell.get("status") != "fail":
|
||||
continue
|
||||
old_cell = old_cells.get((feature_id, provider))
|
||||
if old_cell is None or old_cell.get("status") != "pass":
|
||||
continue
|
||||
regressions.append(
|
||||
{
|
||||
"feature_id": str(feature_id),
|
||||
"feature_name": feature_names.get(feature_id, str(feature_id)),
|
||||
"provider": str(provider),
|
||||
"old_status": "pass",
|
||||
"new_status": "fail",
|
||||
"error": str(new_cell.get("error", "")),
|
||||
}
|
||||
)
|
||||
return regressions
|
||||
|
||||
|
||||
def build_from_paths(
|
||||
*,
|
||||
manifest_path: Path,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ Mock LLM server for UI e2e tests.
|
|||
Responds to OpenAI-format endpoints with canned responses.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
|
|
@ -117,4 +118,12 @@ async def embeddings(request: Request):
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="127.0.0.1", port=8090)
|
||||
# The port is overridable so two checkouts can run the harness at the same
|
||||
# time; the default keeps every existing caller (run_e2e.sh, the CircleCI
|
||||
# job, the e2e chart's sidecar) working untouched.
|
||||
#
|
||||
# The HOST is deliberately NOT configurable. Binding loopback is what makes
|
||||
# this reachable at 127.0.0.1:8090 from inside the proxy's own pod, which is
|
||||
# the contract the deployed config.yml and the e2e values file are written
|
||||
# against.
|
||||
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("MOCK_LLM_PORT", "8090")))
|
||||
|
|
|
|||
65
tests/e2e/ui/helpers/mcp.ts
Normal file
65
tests/e2e/ui/helpers/mcp.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { expect, Page as PwPage } from "@playwright/test";
|
||||
import { navigateToPage } from "./navigation";
|
||||
import { Page } from "../fixtures/pages";
|
||||
import { masterKey } from "./traffic";
|
||||
|
||||
/** Creates an MCP server through the UI's discovery to custom-form flow and returns its name. */
|
||||
export async function createMcpServer(page: PwPage, url: string): Promise<string> {
|
||||
await navigateToPage(page, Page.McpServers);
|
||||
|
||||
await page.getByRole("button", { name: /Add New MCP Server/i }).click();
|
||||
const discovery = page.getByRole("dialog").filter({ hasText: "Add MCP Server" });
|
||||
await expect(discovery).toBeVisible({ timeout: 5_000 });
|
||||
await discovery.getByRole("button", { name: /Custom Server/i }).click();
|
||||
|
||||
const formModal = page.locator(".ant-modal:visible").filter({ hasText: "MCP Server Name" });
|
||||
await expect(formModal).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// validateMCPServerName rejects spaces and hyphens; the worker index avoids a same-millisecond collision.
|
||||
const name = `e2e_mcp_${process.env.TEST_WORKER_INDEX ?? "0"}_${Date.now()}`;
|
||||
await formModal.locator('input[id="server_name"]').fill(name);
|
||||
|
||||
const transportField = formModal.locator(".ant-form-item", { hasText: "Transport Type" });
|
||||
await transportField.locator(".ant-select").click();
|
||||
await page.locator(".ant-select-dropdown:visible").getByText("Streamable HTTP").click();
|
||||
|
||||
await formModal.locator('input[id="url"]').fill(url);
|
||||
|
||||
// The auth_type Form.Item has no label prop, so anchor on the enclosing Collapse panel.
|
||||
const authSection = formModal.locator(".ant-collapse-item", { hasText: /^Authentication/ });
|
||||
await authSection.locator(".ant-form-item").first().locator(".ant-select").click();
|
||||
await page.locator(".ant-select-dropdown:visible").getByText("None", { exact: true }).click();
|
||||
|
||||
await formModal.getByRole("button", { name: /^Add MCP Server$/ }).click();
|
||||
await expect(page.getByText("MCP Server created successfully").first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const card = page.getByTestId("mcp-servers-grid").getByText(name).first();
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every server carrying `serverName`. Leaked servers break unrelated MCP specs: the page
|
||||
* reaches out to each one it lists, so unreachable leftovers stall networkidle until it times out.
|
||||
* Errors are swallowed because this runs from afterEach.
|
||||
*/
|
||||
export async function deleteMcpServerByName(page: PwPage, serverName: string): Promise<void> {
|
||||
const headers = { Authorization: `Bearer ${masterKey()}` };
|
||||
try {
|
||||
const res = await page.request.get("/v1/mcp/server", { headers });
|
||||
if (!res.ok()) return;
|
||||
const servers = (await res.json()) as { server_id: string; server_name?: string }[];
|
||||
for (const server of servers.filter((candidate) => candidate.server_name === serverName)) {
|
||||
await page.request.delete(`/v1/mcp/server/${server.server_id}`, { headers });
|
||||
}
|
||||
} catch {
|
||||
// best effort, see above
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a server from the grid and switches to its MCP Tools tab. */
|
||||
export async function openMcpToolsTab(page: PwPage, serverName: string): Promise<void> {
|
||||
await page.getByTestId("mcp-servers-grid").getByText(serverName).first().click();
|
||||
await expect(page.getByRole("button", { name: /Back to All Servers/i })).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole("tab", { name: "MCP Tools" }).click();
|
||||
}
|
||||
46
tests/e2e/ui/helpers/playground.ts
Normal file
46
tests/e2e/ui/helpers/playground.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { expect, type Locator, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "./navigation";
|
||||
import { Page } from "../fixtures/pages";
|
||||
|
||||
/** Controls for the Test Key / Playground page, shared with the router-fallback specs. */
|
||||
|
||||
/**
|
||||
* The configuration panel is rendered twice, docked and overlay, with one visible at a time.
|
||||
* Every control is narrowed to the visible copy or it trips strict mode against its hidden twin.
|
||||
*/
|
||||
export const onlyVisible = (locator: Locator): Locator => locator.filter({ visible: true }).first();
|
||||
|
||||
/** The model dropdown, addressed by the placeholder it shows before selection. */
|
||||
export const modelSelect = (page: PlaywrightPage): Locator =>
|
||||
onlyVisible(page.locator('.ant-select:has(.ant-select-selection-placeholder:text-is("Select a Model"))'));
|
||||
|
||||
/** Send button is icon-only (an up-arrow), so there is no accessible name. */
|
||||
export const sendButton = (page: PlaywrightPage): Locator => onlyVisible(page.locator("button:has(.anticon-arrow-up)"));
|
||||
|
||||
/** The Virtual Key Source dropdown, addressed by its currently selected label. */
|
||||
export const keySourceSelect = (page: PlaywrightPage, current: string): Locator =>
|
||||
onlyVisible(page.locator(`.ant-select:has(.ant-select-selection-item[title="${current}"])`));
|
||||
|
||||
export async function openPlayground(page: PlaywrightPage): Promise<void> {
|
||||
await navigateToPage(page, Page.LlmPlayground);
|
||||
await dismissFeedbackPopup(page);
|
||||
await expect(onlyVisible(page.getByText("Virtual Key Source"))).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function selectModel(page: PlaywrightPage, model: string): Promise<void> {
|
||||
const select = modelSelect(page);
|
||||
await select.click();
|
||||
// Virtualized: options outside the rendered window are absent from the DOM, so search first.
|
||||
await select.locator("input.ant-select-selection-search-input").fill(model);
|
||||
// antd portals its dropdown to the body; options carry the value as `title`.
|
||||
await onlyVisible(page.locator(`.ant-select-item-option[title="${model}"]`)).click({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function sendMessage(page: PlaywrightPage, message: string): Promise<void> {
|
||||
const input = onlyVisible(page.getByPlaceholder("Type your message", { exact: false }));
|
||||
await expect(input).toBeVisible({ timeout: 15_000 });
|
||||
await input.fill(message);
|
||||
await sendButton(page).click();
|
||||
}
|
||||
28
tests/e2e/ui/helpers/roundTrip.ts
Normal file
28
tests/e2e/ui/helpers/roundTrip.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { expect, Page } from "@playwright/test";
|
||||
import { masterKey } from "./traffic";
|
||||
|
||||
/**
|
||||
* Runs `action` and returns the parsed body of the first matching request.
|
||||
*
|
||||
* `action` is a callback so the listener is armed before the click; awaiting the
|
||||
* click first lets the request go by, and the test then hangs until timeout.
|
||||
*/
|
||||
export async function captureRequestBody(
|
||||
page: Page,
|
||||
match: { method: string; urlIncludes: string },
|
||||
action: () => Promise<void>,
|
||||
): Promise<Record<string, any>> {
|
||||
const pending = page.waitForRequest((req) => req.method() === match.method && req.url().includes(match.urlIncludes));
|
||||
await action();
|
||||
const request = await pending;
|
||||
return JSON.parse(request.postData() ?? "{}") as Record<string, any>;
|
||||
}
|
||||
|
||||
/** Reads an endpoint as the master key, so a failure is bad data and not an expired UI token. */
|
||||
export async function readBack<T = any>(page: Page, endpoint: string): Promise<T> {
|
||||
const res = await page.request.get(endpoint, {
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
});
|
||||
expect(res.ok(), `GET ${endpoint}`).toBe(true);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
125
tests/e2e/ui/helpers/traffic.ts
Normal file
125
tests/e2e/ui/helpers/traffic.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { APIRequestContext, expect } from "@playwright/test";
|
||||
|
||||
/** Model names served by fixtures/config.yml, both backed by the mock LLM server. */
|
||||
export const CHAT_MODEL_A = "fake-openai-gpt-4";
|
||||
export const CHAT_MODEL_B = "fake-anthropic-claude";
|
||||
|
||||
/** The only completion text fixtures/mock_llm_server/server.py ever returns. */
|
||||
export const MOCK_RESPONSE_TEXT = "This is a mock response.";
|
||||
|
||||
export const masterKey = (): string => process.env.LITELLM_MASTER_KEY || "sk-1234";
|
||||
|
||||
const rootPath = (): string => process.env.SERVER_ROOT_PATH ?? "";
|
||||
|
||||
interface ChatOptions {
|
||||
model: string;
|
||||
prompt: string;
|
||||
apiKey?: string;
|
||||
/** Sent as `user`, which lands in the spend log's end_user column. */
|
||||
endUser?: string;
|
||||
}
|
||||
|
||||
/** POST /v1/chat/completions and return the completion id (the Logs Request ID). */
|
||||
export async function sendChatCompletion(request: APIRequestContext, opts: ChatOptions): Promise<string> {
|
||||
const res = await request.post(`${rootPath()}/v1/chat/completions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${opts.apiKey ?? masterKey()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: {
|
||||
model: opts.model,
|
||||
messages: [{ role: "user", content: opts.prompt }],
|
||||
...(opts.endUser ? { user: opts.endUser } : {}),
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `chat completion for ${opts.model} failed (${res.status()}): ${await res.text()}`).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.choices?.[0]?.message?.content).toContain(MOCK_RESPONSE_TEXT);
|
||||
return body.id as string;
|
||||
}
|
||||
|
||||
/** `key` is the sk- value to authenticate with; `token` is its hash, which spend aggregates are keyed by. */
|
||||
export async function createVirtualKey(
|
||||
request: APIRequestContext,
|
||||
data: Record<string, unknown> = {},
|
||||
): Promise<{ key: string; token: string; alias?: string }> {
|
||||
const res = await request.post(`${rootPath()}/key/generate`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${masterKey()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data,
|
||||
});
|
||||
expect(res.ok(), `key generate failed (${res.status()}): ${await res.text()}`).toBe(true);
|
||||
const body = await res.json();
|
||||
return {
|
||||
key: body.key as string,
|
||||
token: (body.token ?? body.token_id) as string,
|
||||
alias: body.key_alias as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spend logs are flushed on a timer, so an assertion straight after a completion races the writer. */
|
||||
export async function waitForSpendLog(
|
||||
request: APIRequestContext,
|
||||
requestId: string,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastStatus = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await request.get(`${rootPath()}/spend/logs?request_id=${encodeURIComponent(requestId)}`, {
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
});
|
||||
lastStatus = res.status();
|
||||
if (res.ok()) {
|
||||
const body = await res.json();
|
||||
const rows = Array.isArray(body) ? body : (body?.data ?? []);
|
||||
if (rows.length > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
}
|
||||
throw new Error(`spend log for request ${requestId} never appeared (last /spend/logs status ${lastStatus})`);
|
||||
}
|
||||
|
||||
const isoDay = (d: Date): string => d.toISOString().slice(0, 10);
|
||||
|
||||
/**
|
||||
* The Usage page reads /user/daily/activity, a rollup written by a background job, and fetches it once
|
||||
* on mount. Navigating before the rollup lands leaves a stale render that never refreshes.
|
||||
*/
|
||||
export async function waitForKeyInDailyActivity(
|
||||
request: APIRequestContext,
|
||||
keyToken: string,
|
||||
timeoutMs = 120_000,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - 7);
|
||||
const query = `start_date=${isoDay(start)}&end_date=${isoDay(now)}`;
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastStatus = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await request.get(`${rootPath()}/user/daily/activity?${query}`, {
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
});
|
||||
lastStatus = res.status();
|
||||
if (res.ok()) {
|
||||
const body = await res.json();
|
||||
const seen = (body?.results ?? []).some(
|
||||
(day: { breakdown?: { api_keys?: Record<string, unknown> } }) => keyToken in (day.breakdown?.api_keys ?? {}),
|
||||
);
|
||||
if (seen) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 3_000));
|
||||
}
|
||||
throw new Error(
|
||||
`key ${keyToken} never appeared in /user/daily/activity (last status ${lastStatus}); ` +
|
||||
"the daily spend rollup may not be running",
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@ set -euo pipefail
|
|||
# ./run_e2e.sh --repeat-each=5 # Run each test 5 times
|
||||
# ./run_e2e.sh --headed # Run with browser visible
|
||||
#
|
||||
# Ports default to 4000 / 5432 / 8090 and can be moved when another checkout
|
||||
# already holds them:
|
||||
# PROXY_PORT=4100 POSTGRES_PORT=5532 MOCK_LLM_PORT=8190 ./run_e2e.sh
|
||||
#
|
||||
# In CI (CI=true), expects:
|
||||
# - PostgreSQL already running on 127.0.0.1:5432
|
||||
# - DATABASE_URL already set
|
||||
|
|
@ -28,12 +32,50 @@ MOCK_PID=""
|
|||
PROXY_PID=""
|
||||
PROXY_LOG=""
|
||||
|
||||
# Ports, overridable so two checkouts can run this harness at the same time --
|
||||
# otherwise a second run aborts on "port 4000 is in use" and the only way out is
|
||||
# to stop someone else's stack. Defaults are the historical values, so an unset
|
||||
# environment behaves exactly as before (CI, the CircleCI job and the docs all
|
||||
# assume 4000/5432/8090).
|
||||
PROXY_PORT="${PROXY_PORT:-4000}"
|
||||
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||
MOCK_LLM_PORT="${MOCK_LLM_PORT:-8090}"
|
||||
export MOCK_LLM_PORT
|
||||
|
||||
# --- Ensure common tool paths are available (local dev only) ---
|
||||
if [ "$IS_CI" = "false" ]; then
|
||||
for p in /usr/local/bin /opt/homebrew/bin "$HOME/.local/bin" /opt/homebrew/opt/postgresql@14/bin /opt/homebrew/opt/libpq/bin; do
|
||||
[ -d "$p" ] && export PATH="$p:$PATH"
|
||||
done
|
||||
[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
|
||||
# Sourcing nvm only makes `nvm` available -- it leaves you on whatever the
|
||||
# default alias points at, which is frequently an older Node than the
|
||||
# dashboard's engines allow. `npm install` then fails EBADENGINE, npm exits
|
||||
# non-zero, and because the install below is `--silent ... || true` the error
|
||||
# is swallowed and the run dies later with the far less obvious
|
||||
# "sh: next: command not found".
|
||||
#
|
||||
# So select a Node that satisfies ui/litellm-dashboard's engines.node, and if
|
||||
# none is available say so here rather than 200 lines downstream.
|
||||
if [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$HOME/.nvm/nvm.sh"
|
||||
required_major="$(sed -nE 's/.*"node"[[:space:]]*:[[:space:]]*">=?([0-9]+).*/\1/p' \
|
||||
"$DASHBOARD_DIR/package.json" 2>/dev/null | head -1)"
|
||||
if [ -n "$required_major" ]; then
|
||||
current_major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/')"
|
||||
if [ -z "$current_major" ] || [ "$current_major" -lt "$required_major" ]; then
|
||||
echo "Node $(node --version 2>/dev/null || echo 'not found') is below the dashboard's required v${required_major}; selecting a newer one via nvm"
|
||||
nvm use "$required_major" >/dev/null 2>&1 || nvm use --lts >/dev/null 2>&1 || true
|
||||
current_major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/')"
|
||||
if [ -z "$current_major" ] || [ "$current_major" -lt "$required_major" ]; then
|
||||
echo "Error: ui/litellm-dashboard requires Node >= v${required_major}, and no such version is installed."
|
||||
echo " Install one with: nvm install ${required_major}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "Using Node $(node --version) / npm $(npm --version)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Cleanup on exit ---
|
||||
|
|
@ -47,7 +89,11 @@ cleanup() {
|
|||
fi
|
||||
echo "Done."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
on_signal() {
|
||||
exit 130
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap on_signal INT TERM
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
for cmd in python3 npx uv; do
|
||||
|
|
@ -59,9 +105,14 @@ if [ "$IS_CI" = "false" ]; then
|
|||
for cmd in docker psql; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "Error: $cmd not found."; exit 1; }
|
||||
done
|
||||
for port in 4000 5432 8090; do
|
||||
if lsof -ti ":$port" >/dev/null 2>&1; then
|
||||
echo "Error: port $port is in use"
|
||||
# Only a LISTENER conflicts with us. Without -sTCP:LISTEN this also matches
|
||||
# ESTABLISHED sockets, so an unrelated *outbound* connection from this machine
|
||||
# to someone else's :5432 (a psql session, a running app, a Prisma engine
|
||||
# talking to a remote database) aborts the run with "port 5432 is in use"
|
||||
# while nothing is actually bound locally.
|
||||
for port in "$PROXY_PORT" "$POSTGRES_PORT" "$MOCK_LLM_PORT"; do
|
||||
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "Error: port $port is in use (override with PROXY_PORT / POSTGRES_PORT / MOCK_LLM_PORT)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
|
@ -69,12 +120,12 @@ if [ "$IS_CI" = "false" ]; then
|
|||
export POSTGRES_USER="e2euser"
|
||||
export POSTGRES_PASSWORD="$(openssl rand -hex 32)"
|
||||
export POSTGRES_DB="litellm_e2e"
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}"
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT}/${POSTGRES_DB}"
|
||||
|
||||
echo "=== Starting PostgreSQL ==="
|
||||
docker run -d --rm --name "$CONTAINER_NAME" \
|
||||
-e POSTGRES_USER -e POSTGRES_PASSWORD -e POSTGRES_DB \
|
||||
-p 127.0.0.1:5432:5432 \
|
||||
-p "127.0.0.1:${POSTGRES_PORT}:5432" \
|
||||
postgres:16
|
||||
|
||||
echo "Waiting for PostgreSQL..."
|
||||
|
|
@ -91,8 +142,13 @@ fi
|
|||
|
||||
# --- Credentials ---
|
||||
export LITELLM_MASTER_KEY="sk-1234"
|
||||
export MOCK_LLM_URL="http://127.0.0.1:8090/v1"
|
||||
export MOCK_LLM_URL="http://127.0.0.1:${MOCK_LLM_PORT}/v1"
|
||||
export DISABLE_SCHEMA_UPDATE="true"
|
||||
# The suite resolves its target from E2E_UI_BASE_URL (constants.ts), which
|
||||
# otherwise defaults to :4000 -- so without this a relocated stack would be
|
||||
# built and booted correctly and then tested against whatever happens to be
|
||||
# listening on the default port.
|
||||
export E2E_UI_BASE_URL="${E2E_UI_BASE_URL:-http://127.0.0.1:${PROXY_PORT}}"
|
||||
# Ensure the proxy serves UI at /ui (not behind a subpath)
|
||||
export SERVER_ROOT_PATH=""
|
||||
# Boot with an external logout URL so proxyLogoutUrl.spec.ts can assert the
|
||||
|
|
@ -108,7 +164,11 @@ export LITELLM_LICENSE="${LITELLM_LICENSE:-}"
|
|||
# --- Rebuild UI from source ---
|
||||
echo "=== Building UI from source ==="
|
||||
cd "$DASHBOARD_DIR"
|
||||
npm install --silent 2>/dev/null || true
|
||||
# NOT silenced, and NOT `|| true`. Swallowing this is what turns a one-line
|
||||
# EBADENGINE ("dashboard requires node >=24, you have v20") into the
|
||||
# considerably less helpful "sh: next: command not found" from the build below,
|
||||
# because the deps that provide `next` were never installed.
|
||||
npm install
|
||||
npm run build
|
||||
# Copy the fresh build to the proxy's static UI directory
|
||||
cp -r "$DASHBOARD_DIR/out/" "$REPO_ROOT/litellm/proxy/_experimental/out/"
|
||||
|
|
@ -139,7 +199,7 @@ uv run --no-sync python "$SCRIPT_DIR/fixtures/mock_llm_server/server.py" &
|
|||
MOCK_PID=$!
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
if curl -sf http://127.0.0.1:8090/health >/dev/null 2>&1; then break; fi
|
||||
if curl -sf http://127.0.0.1:${MOCK_LLM_PORT}/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
|
|
@ -149,7 +209,7 @@ cd "$REPO_ROOT"
|
|||
PROXY_LOG="${TMPDIR:-/tmp}/litellm-e2e-proxy-$$.log"
|
||||
uv run --no-sync python -m litellm.proxy.proxy_cli \
|
||||
--config "$SCRIPT_DIR/fixtures/config.yml" \
|
||||
--port 4000 >"$PROXY_LOG" 2>&1 &
|
||||
--port "$PROXY_PORT" >"$PROXY_LOG" 2>&1 &
|
||||
PROXY_PID=$!
|
||||
|
||||
echo "Waiting for proxy (logs: $PROXY_LOG)..."
|
||||
|
|
@ -160,7 +220,7 @@ for i in $(seq 1 180); do
|
|||
tail -n 100 "$PROXY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4000/health -H "Authorization: Bearer $LITELLM_MASTER_KEY" 2>/dev/null || true)
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${PROXY_PORT}/health -H "Authorization: Bearer $LITELLM_MASTER_KEY" 2>/dev/null || true)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
PROXY_READY=1
|
||||
break
|
||||
|
|
@ -188,9 +248,38 @@ PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAM
|
|||
# --- Playwright ---
|
||||
echo "=== Installing Playwright dependencies ==="
|
||||
cd "$SCRIPT_DIR"
|
||||
npm install --silent 2>/dev/null || true
|
||||
# Same reasoning as the dashboard install above: a failure here means the suite
|
||||
# has no @playwright/test, and the run should say that rather than fail later.
|
||||
npm install
|
||||
npx playwright install chromium --with-deps 2>/dev/null || npx playwright install chromium
|
||||
|
||||
# Authoring a new spec means running it over and over against a stack that is
|
||||
# already up -- rebuilding the UI and re-seeding for every iteration costs
|
||||
# minutes each time. E2E_KEEP_ALIVE brings the stack up, then blocks, so you can
|
||||
# run `npx playwright test <spec>` yourself from another shell against it.
|
||||
# Ctrl-C here tears everything down through the usual trap.
|
||||
if [ "${E2E_KEEP_ALIVE:-0}" = "1" ]; then
|
||||
cat <<EOF
|
||||
|
||||
=== Stack is up (E2E_KEEP_ALIVE=1); not running tests ===
|
||||
UI / API : http://127.0.0.1:${PROXY_PORT}
|
||||
Mock LLM : http://127.0.0.1:${MOCK_LLM_PORT}/v1
|
||||
Database : $DATABASE_URL
|
||||
Proxy log: $PROXY_LOG
|
||||
|
||||
Run specs against it from $SCRIPT_DIR:
|
||||
npx playwright test --config playwright.config.ts <spec>
|
||||
|
||||
Press Ctrl-C to tear the stack down.
|
||||
EOF
|
||||
while kill -0 "$PROXY_PID" 2>/dev/null; do
|
||||
sleep 5
|
||||
done
|
||||
echo "Error: proxy process exited unexpectedly. Proxy output:"
|
||||
tail -n 100 "$PROXY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Running Playwright tests ==="
|
||||
npx playwright test --config playwright.config.ts "$@"
|
||||
EXIT_CODE=$?
|
||||
|
|
|
|||
221
tests/e2e/ui/tests/logs/logs.spec.ts
Normal file
221
tests/e2e/ui/tests/logs/logs.spec.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { test, expect, type Locator, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { CHAT_MODEL_A, MOCK_RESPONSE_TEXT, sendChatCompletion, waitForSpendLog } from "../../helpers/traffic";
|
||||
|
||||
/**
|
||||
* Anchored to traffic this spec generates itself, with a unique prompt and end user per run, so it
|
||||
* neither depends on seeded spend rows nor collides with other specs under parallelism.
|
||||
*/
|
||||
|
||||
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
/**
|
||||
* Walking up from the label is the only stable handle: the header carries no role, test id or class,
|
||||
* and its copy button is icon-only with a hover-only tooltip.
|
||||
*/
|
||||
const sectionHeader = (drawer: Locator, label: "Input" | "Output"): Locator =>
|
||||
drawer.getByText(label, { exact: true }).locator("xpath=../../..");
|
||||
|
||||
/** Every tab stays mounted, so the DOM holds four tables at once; scope to the visible one. */
|
||||
const requestLogsRows = (page: PlaywrightPage): Locator =>
|
||||
page.locator("table").filter({ visible: true }).first().locator("tbody tr");
|
||||
|
||||
const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true });
|
||||
|
||||
/** Open the Logs page and filter the table down to a single request id. */
|
||||
async function openLogsForRequest(page: PlaywrightPage, requestId: string): Promise<Locator> {
|
||||
await navigateToPage(page, Page.Logs);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
const search = visibleTestId(page, "datatable-search");
|
||||
await expect(search).toBeVisible({ timeout: 20_000 });
|
||||
await search.fill(requestId);
|
||||
|
||||
const row = requestLogsRows(page).filter({ hasText: requestId });
|
||||
await expect(row, `no logs row for request ${requestId}`).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
test.describe("Logs page", () => {
|
||||
test.use({
|
||||
storageState: ADMIN_STORAGE_PATH,
|
||||
// The copy buttons go through navigator.clipboard, which rejects without these.
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
});
|
||||
|
||||
test("a served request expands to its request and response", async ({ page, request }) => {
|
||||
const prompt = `logs-detail-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
|
||||
// Expand: clicking the row opens the detail drawer for that request.
|
||||
await row.click();
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer).toBeVisible({ timeout: 20_000 });
|
||||
await expect(drawer.getByText("Request & Response")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The prompt we sent and the mock server's reply are both rendered.
|
||||
await expect(drawer.getByText(prompt, { exact: false })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(drawer.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
// Split out because only the copy path needs a secure context; folding it in would
|
||||
// take the drawer-rendering coverage down with it.
|
||||
test("the drawer copies the request and the response to the clipboard", async ({ page, request }) => {
|
||||
// `navigator.clipboard` is undefined outside a secure context, and handleCopy calls
|
||||
// writeText unguarded, so on plain HTTP served from a hostname the click throws and no
|
||||
// toast renders. Skipped rather than weakened so the product gap stays visible.
|
||||
await page.goto("/ui");
|
||||
const isSecure = await page.evaluate(() => window.isSecureContext);
|
||||
test.skip(!isSecure, "origin is not a secure context, so navigator.clipboard is unavailable");
|
||||
|
||||
const prompt = `logs-copy-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
await row.click();
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Copy request: the Input card's copy button puts the prompt on the clipboard.
|
||||
await sectionHeader(drawer, "Input").getByRole("button").click();
|
||||
await expect(page.getByText("Input copied")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain(prompt);
|
||||
|
||||
// Copy response: the Output card's copy button puts the completion on it.
|
||||
await sectionHeader(drawer, "Output").getByRole("button").click();
|
||||
await expect(page.getByText("Output copied")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain(MOCK_RESPONSE_TEXT);
|
||||
});
|
||||
|
||||
test("the Input card collapses and expands", async ({ page, request }) => {
|
||||
const prompt = `logs-collapse-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
await row.click();
|
||||
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer.getByText("Request & Response")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The body collapses via `max-height: 0; overflow: hidden`, which zeroes its own bounding
|
||||
// box, so the wrapper reads as hidden while the clipped text node inside it does not.
|
||||
const header = sectionHeader(drawer, "Input");
|
||||
const body = header.locator("xpath=following-sibling::div[1]");
|
||||
await expect(header.locator(".anticon-up")).toBeVisible();
|
||||
await expect(body).toBeVisible();
|
||||
|
||||
await header.click();
|
||||
await expect(header.locator(".anticon-down")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(body).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await header.click();
|
||||
await expect(header.locator(".anticon-up")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(body).toBeVisible({ timeout: 10_000 });
|
||||
await expect(drawer.getByText(prompt, { exact: false })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("the JSON view exposes Request and Response tabs", async ({ page, request }) => {
|
||||
const prompt = `logs-json-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
await row.click();
|
||||
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer.getByText("Request & Response")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// antd Radio.Button hides the <input> under its <label>, which intercepts
|
||||
// the pointer event — click the label, not the radio.
|
||||
await drawer.locator("label.ant-radio-button-wrapper").filter({ hasText: "JSON" }).click();
|
||||
|
||||
const requestTab = drawer.getByRole("tab", { name: "Request" });
|
||||
await expect(requestTab).toBeVisible({ timeout: 10_000 });
|
||||
await requestTab.click();
|
||||
await expect(drawer.getByText(prompt, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await drawer.getByRole("tab", { name: "Response" }).click();
|
||||
await expect(drawer.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the End User filter narrows the table to that customer", async ({ page, request }) => {
|
||||
const endUser = `logs-end-user-${uniqueSuffix()}`;
|
||||
const minePrompt = `logs-filter-mine-${uniqueSuffix()}`;
|
||||
const otherPrompt = `logs-filter-other-${uniqueSuffix()}`;
|
||||
|
||||
const mineId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: minePrompt,
|
||||
endUser,
|
||||
});
|
||||
const otherId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: otherPrompt,
|
||||
});
|
||||
await waitForSpendLog(request, mineId);
|
||||
await waitForSpendLog(request, otherId);
|
||||
|
||||
await navigateToPage(page, Page.Logs);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
// Both requests are in the unfiltered table.
|
||||
await expect(requestLogsRows(page).filter({ hasText: mineId })).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(requestLogsRows(page).filter({ hasText: otherId })).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
await visibleTestId(page, "datatable-filters-trigger").click();
|
||||
const filters = page.getByRole("dialog").filter({ hasText: "Narrow down request logs" });
|
||||
await expect(filters).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const endUserInput = filters.getByPlaceholder("Search an end user");
|
||||
await endUserInput.click();
|
||||
await endUserInput.fill(endUser);
|
||||
// The combobox popup is portaled to the body, so it is outside the filter
|
||||
// dialog's subtree — scope the option lookup to the page, not the dialog.
|
||||
await page.getByRole("option", { name: endUser, exact: true }).click({ timeout: 30_000 });
|
||||
await filters.getByRole("button", { name: "Apply Filters" }).click();
|
||||
|
||||
// Only the request tagged with this end user survives the filter.
|
||||
await expect(requestLogsRows(page).filter({ hasText: otherId })).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(requestLogsRows(page).filter({ hasText: mineId })).toHaveCount(1);
|
||||
await expect(requestLogsRows(page)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
92
tests/e2e/ui/tests/mcp/mcpServerEdit.spec.ts
Normal file
92
tests/e2e/ui/tests/mcp/mcpServerEdit.spec.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { createMcpServer, deleteMcpServerByName } from "../../helpers/mcp";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
|
||||
/**
|
||||
* Editing and deleting an MCP server, verified against the API. The reported failures are all on
|
||||
* this side: renames that need repeating, deletes that need two attempts, each toasting success on
|
||||
* the failing attempt. The URL is unreachable on purpose; only persistence is under test here.
|
||||
*/
|
||||
const UNREACHABLE_URL = "https://e2e-fake-mcp.test.local/mcp";
|
||||
|
||||
/** GET /v1/mcp/server returns a bare array of servers (useMCPServers types it MCPServer[]). */
|
||||
async function findServerByName(page: PlaywrightPage, serverName: string): Promise<Record<string, any> | undefined> {
|
||||
const servers = await readBack<Record<string, any>[]>(page, "/v1/mcp/server");
|
||||
return servers.find((server) => server.server_name === serverName);
|
||||
}
|
||||
|
||||
test.describe("MCP Servers - edit and delete", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
let serverName: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
serverName = await createMcpServer(page, UNREACHABLE_URL);
|
||||
});
|
||||
|
||||
// The rename test leaves an unreachable server behind, which slows the MCP page for later tests.
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteMcpServerByName(page, serverName);
|
||||
});
|
||||
|
||||
test("Renaming a server's alias persists", async ({ page }) => {
|
||||
const before = await findServerByName(page, serverName);
|
||||
expect(before, `created server ${serverName} readable from /v1/mcp/server`).toBeTruthy();
|
||||
|
||||
await page.getByTestId("mcp-servers-grid").getByText(serverName).first().click();
|
||||
await expect(page.getByRole("button", { name: /Back to All Servers/i })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// exact: the server view also renders a "Network Settings" tab.
|
||||
await page.getByRole("tab", { name: "Settings", exact: true }).click();
|
||||
|
||||
// A card click may land straight in edit mode, so only click the button when it rendered.
|
||||
const editSettings = page.getByRole("button", { name: "Edit Settings" });
|
||||
if (await editSettings.isVisible().catch(() => false)) {
|
||||
await editSettings.click();
|
||||
}
|
||||
|
||||
// The create modal stays mounted behind the view with its own #alias and Save.
|
||||
const settingsPanel = page.getByRole("tabpanel", { name: "Settings" });
|
||||
|
||||
const newAlias = `${serverName}_renamed`;
|
||||
const aliasInput = settingsPanel.locator('input[id="alias"]');
|
||||
await expect(aliasInput).toBeVisible({ timeout: 10_000 });
|
||||
await aliasInput.fill(newAlias);
|
||||
|
||||
const update = await captureRequestBody(page, { method: "PUT", urlIncludes: "/v1/mcp/server" }, async () => {
|
||||
await settingsPanel.getByRole("button", { name: "Save Changes" }).click();
|
||||
});
|
||||
expect(update.alias, "new alias on the wire").toBe(newAlias);
|
||||
// An unidentified target is one way a save succeeds and changes nothing.
|
||||
expect(update.server_id, "update targets the server being edited").toBe(before?.server_id);
|
||||
|
||||
// The reported symptom is a first save that returns success and does not stick.
|
||||
await expect
|
||||
.poll(async () => (await findServerByName(page, serverName))?.alias, {
|
||||
message: `alias for ${serverName} did not persist after one save`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(newAlias);
|
||||
});
|
||||
|
||||
test("Deleting a server removes it", async ({ page }) => {
|
||||
expect(await findServerByName(page, serverName), `created server ${serverName} exists`).toBeTruthy();
|
||||
|
||||
const card = page.getByTestId("mcp-servers-grid").locator("div").filter({ hasText: serverName }).first();
|
||||
await card.getByRole("button", { name: "Server actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog.getByText("Delete MCP Server?")).toBeVisible({ timeout: 5_000 });
|
||||
await dialog.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
|
||||
// One attempt has to be enough; the report is a delete that needs two.
|
||||
await expect
|
||||
.poll(async () => await findServerByName(page, serverName), {
|
||||
message: `server ${serverName} still present after one delete`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test";
|
|||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { deleteMcpServerByName } from "../../helpers/mcp";
|
||||
|
||||
// Coverage scope: only the happy-path Streamable HTTP + None auth create flow.
|
||||
// See E2E_COVERAGE.md (#29 row) for the full list of uncovered MCP surfaces
|
||||
|
|
@ -11,6 +12,15 @@ import { Page } from "../../fixtures/pages";
|
|||
test.describe("MCP Servers", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
let createdServerName = "";
|
||||
|
||||
// The server this test creates is unreachable, and the MCP page contacts
|
||||
// every server it lists, so leaving it behind slows down every later MCP
|
||||
// test. See deleteMcpServerByName for what that actually cost.
|
||||
test.afterEach(async ({ page }) => {
|
||||
if (createdServerName) await deleteMcpServerByName(page, createdServerName);
|
||||
});
|
||||
|
||||
test("Add a custom MCP server via the discovery → custom form", async ({ page }) => {
|
||||
await navigateToPage(page, Page.McpServers);
|
||||
|
||||
|
|
@ -25,6 +35,7 @@ test.describe("MCP Servers", () => {
|
|||
|
||||
// Name — no spaces or hyphens per validateMCPServerName
|
||||
const uniqueName = `e2e_mcp_${Date.now()}`;
|
||||
createdServerName = uniqueName;
|
||||
await formModal.locator('input[id="server_name"]').fill(uniqueName);
|
||||
|
||||
// Transport: Streamable HTTP — the only value the proxy actually accepts is "http"
|
||||
|
|
@ -48,8 +59,6 @@ test.describe("MCP Servers", () => {
|
|||
// Submit
|
||||
await formModal.getByRole("button", { name: /^Add MCP Server$/ }).click();
|
||||
|
||||
// No teardown needed — the e2e runner spins up a fresh DB per invocation.
|
||||
|
||||
// Success toast and the new card in the server grid. Scope the lookup to
|
||||
// the MCP servers grid so the form modal's `server_name` input — which
|
||||
// still holds the timestamped value during its close animation — can't
|
||||
|
|
|
|||
80
tests/e2e/ui/tests/mcp/mcpTools.spec.ts
Normal file
80
tests/e2e/ui/tests/mcp/mcpTools.spec.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { test, expect, Locator } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { createMcpServer, deleteMcpServerByName, openMcpToolsTab } from "../../helpers/mcp";
|
||||
|
||||
// Listing and calling MCP tools, which needs a server that really answers; the create-only spec
|
||||
// points at an unreachable URL on purpose.
|
||||
//
|
||||
// This spec makes a read-only network call to DeepWiki's public MCP server, from the proxy rather
|
||||
// than the browser. It needs no credentials, so there is no secret to leak from a public repo.
|
||||
//
|
||||
// A DeepWiki outage turns this red for something that is not a litellm regression. That is left
|
||||
// visible rather than auto-skipped: skipping on connection trouble also skips when the proxy's own
|
||||
// MCP client breaks, which is the regression this exists to catch. E2E_SKIP_EXTERNAL_MCP=1 opts out.
|
||||
const MCP_SERVER_URL = "https://mcp.deepwiki.com/mcp";
|
||||
const TOOL_NAME = "read_wiki_structure";
|
||||
const TOOL_ARG_REPO = "BerriAI/litellm";
|
||||
|
||||
// Match the h4 heading, not page text: a tool whose description names another tool trips strict mode.
|
||||
const toolCard = (list: Locator, name: string): Locator =>
|
||||
list.locator("h4.font-mono").filter({ hasText: new RegExp(`^${name}$`) });
|
||||
|
||||
test.describe("MCP Tools", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
test.skip(!!process.env.E2E_SKIP_EXTERNAL_MCP, "E2E_SKIP_EXTERNAL_MCP is set");
|
||||
|
||||
let serverName: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
serverName = await createMcpServer(page, MCP_SERVER_URL);
|
||||
await openMcpToolsTab(page, serverName);
|
||||
});
|
||||
|
||||
// The MCP page contacts every server it lists, so leaks slow later tests run by run.
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteMcpServerByName(page, serverName);
|
||||
});
|
||||
|
||||
test("MCP Tools tab lists the tools the upstream server advertises", async ({ page }) => {
|
||||
// Fetched through the proxy on mount, so allow for a cold upstream connection.
|
||||
const toolList = page.locator(".mcp-tools-scrollable");
|
||||
await expect(toolList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Non-empty would still pass if the proxy returned some other server's tools.
|
||||
await expect(toolCard(toolList, TOOL_NAME)).toBeVisible();
|
||||
await expect(toolCard(toolList, "ask_question")).toBeVisible();
|
||||
await expect(toolCard(toolList, "read_wiki_contents")).toBeVisible();
|
||||
|
||||
// No other tool's name or description contains this string, so exactly one card survives.
|
||||
await page.getByPlaceholder("Search tools...").fill(TOOL_NAME);
|
||||
await expect(toolList.locator("h4.font-mono")).toHaveCount(1);
|
||||
await expect(toolCard(toolList, TOOL_NAME)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Calling a tool from the Test Tool panel returns the upstream result", async ({ page }) => {
|
||||
const toolList = page.locator(".mcp-tools-scrollable");
|
||||
await expect(toolList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await toolCard(toolList, TOOL_NAME).click();
|
||||
|
||||
// Selecting a tool swaps the right-hand pane in for the empty state.
|
||||
await expect(page.getByText("Test Tool:", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("Ready to Call Tool")).toBeVisible();
|
||||
|
||||
// The form is generated from the tool's inputSchema, so `repoName` proves the schema
|
||||
// round-tripped through the proxy instead of the panel falling back to a generic field.
|
||||
const repoInput = page.locator('input[id="repoName"]');
|
||||
await expect(repoInput).toBeVisible();
|
||||
await repoInput.fill(TOOL_ARG_REPO);
|
||||
|
||||
await page.getByRole("button", { name: "Call Tool", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Tool executed successfully")).toBeVisible({ timeout: 60_000 });
|
||||
// read_wiki_structure answers with the repo's outline, so the pane must name the repo.
|
||||
await expect(page.getByText(TOOL_ARG_REPO).first()).toBeVisible();
|
||||
|
||||
// A second call is offered rather than the button resetting to its
|
||||
// first-run label.
|
||||
await expect(page.getByRole("button", { name: "Call Again", exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue