Merge remote-tracking branch 'origin/main' into litellm_azure_entra_token_followups
Some checks failed
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled

# Conflicts:
#	litellm/llms/custom_httpx/llm_http_handler.py
This commit is contained in:
mateo-berri 2026-10-20 14:40:34 -07:00
commit 33f6e80aec
1319 changed files with 78883 additions and 13770 deletions

View file

@ -1,90 +0,0 @@
#!/usr/bin/env bash
#
# pre-push — enforce Conventional Branches
# https://conventional-branch.github.io/
#
# Branch format: <type>/<description>
# <type> must be one of: feature, bugfix, hotfix, release, chore
#
# Protected branches (always allowed):
# - main
# - dependabot/*
# - gh-readonly-queue/*
#
# Tag pushes and branch deletions are skipped.
# Bypass: git push --no-verify.
set -eu
ZERO_OID="0000000000000000000000000000000000000000"
ZERO_OID_SHA256="0000000000000000000000000000000000000000000000000000000000000000"
ALLOWED_TYPES="feature|bugfix|hotfix|release|chore"
BRANCH_PATTERN="^(${ALLOWED_TYPES})/.+"
PROTECTED_NAMES="main"
PROTECTED_PREFIXES="dependabot/ gh-readonly-queue/"
is_protected() {
branch="$1"
for name in $PROTECTED_NAMES; do
if [ "$branch" = "$name" ]; then
return 0
fi
done
for prefix in $PROTECTED_PREFIXES; do
case "$branch" in "$prefix"*) return 0 ;; esac
done
return 1
}
invalid=""
while read -r local_ref local_oid remote_ref remote_oid; do
# Branch deletion (no local commit being pushed).
if [ "$local_oid" = "$ZERO_OID" ] || [ "$local_oid" = "$ZERO_OID_SHA256" ]; then
continue
fi
# Only validate branch pushes; ignore tags and other ref namespaces.
case "$remote_ref" in
refs/heads/*) ;;
*) continue ;;
esac
branch="${remote_ref#refs/heads/}"
if is_protected "$branch"; then
continue
fi
if ! printf '%s' "$branch" | grep -Eq "$BRANCH_PATTERN"; then
invalid="$invalid $branch"
fi
done
if [ -n "$invalid" ]; then
cat >&2 <<EOF
✗ Branch name does not follow Conventional Branches.
Invalid:$invalid
Expected: <type>/<description>
Allowed types: feature, bugfix, hotfix, release, chore
Examples:
feature/weighted-round-robin
bugfix/streaming-empty-chunks
chore/bump-deps
hotfix/auth-bypass
Protected (always allowed): main, dependabot/*, gh-readonly-queue/*.
See https://conventional-branch.github.io/
Rename with: git branch -m <new-name>
To bypass (use sparingly): git push --no-verify
EOF
exit 1
fi
exit 0

2
.github/CODEOWNERS vendored
View file

@ -1,2 +1,4 @@
/model_prices_and_context_window.json @mateo-berri @ryan-crabbe-berri @kerry-berri
/litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerry-berri
/helm/ @yassin-berriai
/terraform/ @yassin-berriai

View file

@ -4,6 +4,13 @@
Drop every section you have nothing to put in, heading included: a bare "## Relevant issues" or
"## Affected release" with nothing under it must not appear in the final description -->
<!-- Two or three plain sentences a teammate can read in 10 seconds, written the way you'd explain it in Slack
Say what the PR does for users and why, then anything that keeps it working over time (a sync job, a config someone owns)
No headings, bullets, tables, or code names here. Everything else goes inside the Details block below -->
<details>
<summary>Details</summary>
## TLDR
<!-- Fill in the bullets below and keep each one short and concrete: one line per bullet, roughly 10 words max
@ -164,3 +171,5 @@ Example checklists:
## Final Attestation
- [ ] The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
</details>

View file

@ -5,7 +5,7 @@ description: >-
test_paths:
- reason: >-
litellm.agent() end-to-end suite. It drives the real claude, codex and opencode CLIs and
litellm.agent() end-to-end suite. It drives the real claude, codex, opencode and pi CLIs and
deepagents against a live LiteLLM AI Gateway, so it needs those binaries on PATH plus
LITELLM_PROXY_API_BASE / LITELLM_PROXY_API_KEY, and skips without them. Run manually
before changing litellm/harness; the mocked coverage runs in tests/unit/harness and

View file

@ -266,6 +266,7 @@ jobs:
tests/unit/llms/featherless_ai
tests/unit/llms/firecrawl
tests/unit/llms/fireworks_ai
tests/unit/llms/friendliai
tests/unit/llms/gdc
tests/unit/llms/gemini
tests/unit/llms/gigachat
@ -321,6 +322,7 @@ jobs:
tests/unit/llms/pass_through
tests/unit/llms/perplexity
tests/unit/llms/pg_vector
tests/unit/llms/pi
tests/unit/llms/publicai
tests/unit/llms/ragflow
tests/unit/llms/recraft
@ -410,6 +412,7 @@ jobs:
tests/unit/embeddings
tests/unit/endpoints
tests/unit/files
tests/unit/fine_tuning
tests/unit/images
tests/unit/interactions
tests/unit/messages

View file

@ -39,12 +39,12 @@ git clone https://github.com/YOUR_USERNAME/litellm.git
cd litellm
# Create a new branch for your feature (see "Commit and Branch Conventions" below)
git checkout -b feature/your-feature
git checkout -b litellm_your_feature
# Install development dependencies
make install-dev
# Install git hooks that enforce commit + branch conventions (one-time, opt-in)
# Install git hooks that enforce commit conventions (one-time, opt-in)
make install-hooks
# Verify your setup works
@ -55,7 +55,7 @@ That's it! Your local development environment is ready.
## Commit and Branch Conventions
Commits follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) and branches follow [Conventional Branches](https://conventional-branch.github.io/). Run `make install-hooks` once per clone to enable the local git hooks that enforce these — see the [contributor docs](https://docs.litellm.ai/docs/extras/contributing_code#commit-and-branch-conventions) for the full type list, examples, the protected-branch bypass list, and how to opt out.
Commits follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). Internal contributors name branches with the `litellm_` prefix and no `/`, and fork branches can use any name. Run `make install-hooks` once per clone to enable the local hook that enforces the commit convention. See the [contributor docs](https://docs.litellm.ai/docs/extras/contributing_code#commit-and-branch-conventions) for details
### 2. Development Workflow
@ -78,8 +78,8 @@ uv run pytest tests/unit/<your_test_file>.py -v
git add .
git commit -m "feat(scope): your descriptive commit message"
# Push and create a PR (branch must follow Conventional Branches — see above)
git push origin feature/your-feature
# Push and create a PR
git push origin litellm_your_feature
```
## Adding Testing

View file

@ -22,7 +22,7 @@ help:
@echo " make install-proxy-dev-ci - Install proxy dev dependencies (CI-compatible)"
@echo " make install-test-deps - Install the full local test environment"
@echo " make install-helm-unittest - Install helm unittest plugin"
@echo " make install-hooks - Install git hooks (Conventional Commits + Branches)"
@echo " make install-hooks - Install git hooks (Conventional Commits)"
@echo " make check - Run CI-equivalent lint on staged files, or on the diff vs the base branch when nothing is staged"
@echo " make pre-commit - Legacy alias for make check"
@echo " make format - Apply ruff format code formatting"
@ -120,7 +120,7 @@ install-helm-unittest:
helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.8.2; \
}
# Install git hooks that enforce Conventional Commits and Conventional Branches.
# Install git hooks that enforce Conventional Commits.
# Opt-in: not chained into install-dev.
install-hooks:
./scripts/install_git_hooks.sh

View file

@ -287,7 +287,7 @@ For MCP OAuth, an upstream may advertise dynamic client registration but refuse
</details>
<details>
<summary><b>Agents</b> - Run Claude Code, Codex, OpenCode or Deep Agents on any model (Python SDK)</summary>
<summary><b>Agents</b> - Run Claude Code, Codex, OpenCode, pi or Deep Agents on any model (Python SDK)</summary>
### Python SDK - Agents
@ -296,7 +296,7 @@ import litellm
from litellm import Harness, sandbox
result = litellm.agent(
Harness.CLAUDE_CODE, # or Harness.CODEX, Harness.OPENCODE, Harness.DEEPAGENTS
Harness.CLAUDE_CODE, # or Harness.CODEX, Harness.OPENCODE, Harness.PI, Harness.DEEPAGENTS
"Find why tests/test_router.py is flaky and fix it.",
sandbox=sandbox.local("./repo"),
model="litellm_proxy/claude-sonnet-4-5", # a model group on your AI Gateway
@ -305,7 +305,7 @@ result = litellm.agent(
print(result.text, result.cost, [f.path for f in result.files])
```
Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call the agent makes goes through your AI Gateway, tagged `harness,claude_code`. Drop the `litellm_proxy/` prefix to call a provider directly. Install `starlette uvicorn` plus the agent's CLI (`claude`, `codex` or `opencode`), or `deepagents langchain-litellm` for Deep Agents.
Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call the agent makes goes through your AI Gateway, tagged `harness,claude_code`. Drop the `litellm_proxy/` prefix to call a provider directly. Install `starlette uvicorn` plus the agent's CLI (`claude`, `codex`, `opencode` or `pi`), or `deepagents langchain-litellm` for Deep Agents.
[**Docs: Agent Harnesses**](https://docs.litellm.ai/docs/harness)

View file

@ -0,0 +1,41 @@
# Bedrock batch endpoints
Set `aws_bedrock_control_plane_endpoint` on a deployment when batch create, retrieve, and cancel requests must use a custom Bedrock control-plane endpoint. The proxy signs these requests for the configured URL. Without this setting, batch requests use the standard regional Bedrock endpoint
```yaml
model_list:
- model_name: bedrock-batch
litellm_params:
model: bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
aws_region_name: us-east-1
aws_access_key_id: os.environ/AWS_ACCESS_KEY_ID
aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY
aws_batch_role_arn: os.environ/AWS_BATCH_ROLE_ARN
s3_output_bucket_name: os.environ/AWS_S3_OUTPUT_BUCKET_NAME
aws_bedrock_control_plane_endpoint: os.environ/BEDROCK_CONTROL_PLANE_ENDPOINT
```
Set `BEDROCK_CONTROL_PLANE_ENDPOINT` to your Bedrock control-plane VPC endpoint or compatible gateway base URL, without `/model-invocation-job`. The proxy adds the operation path. The endpoint must be reachable from the proxy, and the configured AWS region must match the endpoint's signing region
`api_base` and `aws_bedrock_runtime_endpoint` configure runtime inference separately. They do not override the batch control-plane endpoint. `s3_endpoint_url` configures S3 file access separately
The control-plane setting belongs in deployment configuration. Sending `aws_bedrock_control_plane_endpoint` in an API request body is rejected. There is no automatic `AWS_BEDROCK_CONTROL_PLANE_ENDPOINT` environment fallback, so reference the desired environment variable explicitly as shown above
Start the proxy with this configuration and set `LITELLM_API_BASE` to its URL. Set `BEDROCK_BATCH_INPUT_URI` to a valid input JSONL file in S3 that the batch service role can read
```bash
curl -sS "$LITELLM_API_BASE/v1/batches" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H 'Content-Type: application/json' \
-d "{\"model\":\"bedrock-batch\",\"input_file_id\":\"$BEDROCK_BATCH_INPUT_URI\",\"endpoint\":\"/v1/chat/completions\",\"completion_window\":\"24h\"}"
```
Use the returned `id` as `BATCH_ID` to retrieve or cancel the same job through the deployment's configured endpoint
```bash
curl -sS "$LITELLM_API_BASE/v1/batches/$BATCH_ID?model=bedrock-batch" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY"
curl -sS -X POST "$LITELLM_API_BASE/v1/batches/$BATCH_ID/cancel?model=bedrock-batch" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY"
```

View file

@ -20,9 +20,15 @@ class EnterpriseCustomGuardrailHelper:
the tag's value (the mode for that tag).
- If no request tag matches, fall back to default mode(s).
"""
from litellm.litellm_core_utils.core_helpers import (
get_metadata_variable_name_from_kwargs,
)
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
from litellm.router_strategy.tag_based_routing import (
without_baggage_mapped_tags,
)
from litellm.proxy._types import CommonProxyErrors
from litellm.proxy.proxy_server import premium_user
@ -36,9 +42,13 @@ class EnterpriseCustomGuardrailHelper:
proxy_server_request = data.get("proxy_server_request", {})
request_tags = StandardLoggingPayloadSetup.get_request_tags(
litellm_params=data,
proxy_server_request=proxy_server_request,
request_tags = without_baggage_mapped_tags(
StandardLoggingPayloadSetup.get_request_tags(
litellm_params=data,
proxy_server_request=proxy_server_request,
),
data.get(get_metadata_variable_name_from_kwargs(data)),
keep_serving_deployment_tags=True,
)
# Check if any request tag matches a Mode tag key

View file

@ -4,6 +4,7 @@ Polls LiteLLM_ManagedObjectTable to check if the batch job is complete, and if t
import asyncio
import json
import re
from collections.abc import Mapping, Sequence
from dataclasses import replace as dataclasses_replace
from datetime import datetime, timedelta, timezone
@ -39,7 +40,12 @@ if TYPE_CHECKING:
from litellm.types.utils import LiteLLMBatch
def _error_names_resource(error: Exception, resource_id: str) -> bool:
return resource_id in re.findall(r"""[^\s"'`]+""", str(error))
CHECK_BATCH_COST_USER_AGENT = "LiteLLM Proxy/CheckBatchCost"
RECENT_BATCH_POLL_WINDOW: Final = timedelta(hours=48)
PROVIDER_TERMINAL_BATCH_STATUSES: Final[Tuple[str, ...]] = (
"completed",
@ -109,6 +115,11 @@ class CheckBatchCost:
# the guaranteed-failing primary query on every subsequent cycle.
self._has_batch_processed_column: bool = True
self.batch_processed_support_confirmed: bool = False
self._poll_cursor: tuple[datetime, str] | None = None
self._poll_before: datetime = datetime.now(timezone.utc)
self._arrival_cursor: tuple[datetime, str] | None = None
self._arrival_before: datetime = self._poll_before
self._poll_arrivals: bool = False
self._line_item_stores: Final[set[asyncio.Task[None]]] = set()
self._line_item_store_turn: Final = asyncio.Lock()
self._poll_idle: Final = asyncio.Event()
@ -161,29 +172,9 @@ class CheckBatchCost:
f"(older than {MANAGED_OBJECT_STALENESS_CUTOFF_DAYS} days) as stale_expired"
)
if not self._has_batch_processed_column:
return
# A row already in a terminal status is never rewritten by the sweep above, so
# without this it keeps a poll-page slot forever and starves newer batches.
retired: Final = await _managed_object_table(self.prisma_client).update_many(
where={
"file_purpose": "batch",
"batch_processed": False,
"status": {"in": ["complete", "completed"]},
"created_at": {"lt": cutoff},
},
data={"batch_processed": True},
)
if retired > 0:
verbose_proxy_logger.warning(
f"CheckBatchCost: gave up on {retired} completed managed objects older than "
f"{MANAGED_OBJECT_STALENESS_CUTOFF_DAYS} days that were never costed"
)
async def _fallback_find_jobs(self) -> "Sequence[ManagedObjectRow]":
"""Query batch jobs without the batch_processed filter (for older schemas)."""
return await _managed_object_table(self.prisma_client).find_many(
return await self._find_jobs(
where={
"file_purpose": "batch",
"status": {
@ -197,10 +188,59 @@ class CheckBatchCost:
]
},
},
take=MAX_OBJECTS_PER_POLL_CYCLE,
order={"created_at": "asc"},
)
async def _find_jobs(self, where: Mapping[str, object]) -> Sequence[ManagedObjectRow]:
arrivals: Final = self._poll_arrivals
page: Final = await self._find_job_page(where, arrivals=arrivals)
self._poll_arrivals = not arrivals if page else arrivals
if page:
return page
return await self._find_job_page(where, arrivals=not arrivals)
async def _find_job_page(self, where: Mapping[str, object], *, arrivals: bool) -> Sequence[ManagedObjectRow]:
cursor: Final = self._arrival_cursor if arrivals else self._poll_cursor
if cursor is None:
if arrivals:
self._arrival_before = datetime.now(timezone.utc)
else:
self._poll_before = datetime.now(timezone.utc) - RECENT_BATCH_POLL_WINDOW
before: Final = self._arrival_before if arrivals else self._poll_before
page: Final = await _managed_object_table(self.prisma_client).find_many(
where={
**where,
"created_at": {
"lte": before,
**({"gt": before - RECENT_BATCH_POLL_WINDOW} if arrivals else {}),
},
**(
{
"OR": [
{"created_at": {"gt": cursor[0]}},
{"created_at": cursor[0], "id": {"gt": cursor[1]}},
]
}
if cursor is not None
else {}
),
},
take=MAX_OBJECTS_PER_POLL_CYCLE,
order=[{"created_at": "asc"}, {"id": "asc"}],
)
next_cursor: Final = (page[-1].created_at, page[-1].id) if len(page) == MAX_OBJECTS_PER_POLL_CYCLE else None
if arrivals:
self._arrival_cursor = next_cursor
else:
self._poll_cursor = next_cursor
return page
def _unprocessed_snapshot(self, job: ManagedObjectRow) -> Mapping[str, object]:
return {
"id": job.id,
"updated_at": job.updated_at,
**({"batch_processed": False} if self._has_batch_processed_column else {}),
}
async def _retire_job(self, job: "ManagedObjectRow", reason: str) -> None:
"""
Take a row that can never be costed out of the poll page. Leaving it selectable
@ -208,24 +248,19 @@ class CheckBatchCost:
once enough such rows accumulate no newer batch is ever reached. Older schemas
without batch_processed can only be excluded through the status filter.
"""
data: Final = (
{"batch_processed": True}
if self._has_batch_processed_column
else {"status": "stale_expired"}
)
data: Final = {"batch_processed": True} if self._has_batch_processed_column else {"status": "stale_expired"}
try:
await _managed_object_table(self.prisma_client).update(
where={"id": job.id},
retired: Final = await _managed_object_table(self.prisma_client).update_many(
where=self._unprocessed_snapshot(job),
data=data,
)
except Exception as db_err:
verbose_proxy_logger.error(
f"CheckBatchCost: failed to retire uncostable job {job.id} ({reason}): {db_err}"
)
verbose_proxy_logger.error(f"CheckBatchCost: failed to retire uncostable job {job.id} ({reason}): {db_err}")
return
if retired == 0:
return
verbose_proxy_logger.warning(
f"CheckBatchCost: job {job.id} can never be costed ({reason}), "
"so it will no longer be polled"
f"CheckBatchCost: job {job.id} can never be costed ({reason}), so it will no longer be polled"
)
async def _claim_job_for_costing(self, job: "ManagedObjectRow", response: "LiteLLMBatch") -> bool:
@ -312,13 +347,13 @@ class CheckBatchCost:
from litellm.exceptions import NotFoundError
return isinstance(error, (NotFoundError, openai.NotFoundError)) and batch_id in str(error)
return isinstance(error, (NotFoundError, openai.NotFoundError)) and _error_names_resource(error, batch_id)
def _batch_deployment_exists(self, model_id: str) -> bool:
"""A 404 only proves the batch is gone when it came from the batch's own
deployment. Once that deployment leaves the router, default fallbacks can
silently send the retrieve to a provider that never saw the batch, so its
404 must not retire the row; the staleness sweep bounds it instead."""
404 must not retire a potentially recoverable row."""
return self.llm_router.get_deployment(model_id=model_id) is not None
@staticmethod
@ -333,7 +368,7 @@ class CheckBatchCost:
if not output_file_id:
return False
return isinstance(error, (NotFoundError, openai.NotFoundError)) and output_file_id in str(error)
return isinstance(error, (NotFoundError, openai.NotFoundError)) and _error_names_resource(error, output_file_id)
async def _finalize_unbilled_terminal_job(
self, job: "ManagedObjectRow", response: "LiteLLMBatch"
@ -355,18 +390,18 @@ class CheckBatchCost:
db_batch_object=job,
unified_batch_id=_is_base64_encoded_unified_file_id(job.unified_object_id),
)
update_data: Final[dict] = {
update_data: Final[dict[str, object]] = {
"status": response.status,
"file_object": response.model_dump_json(),
**({"batch_processed": True} if self._has_batch_processed_column else {}),
}
await _managed_object_table(self.prisma_client).update(
where={"id": job.id},
finalized: Final = await _managed_object_table(self.prisma_client).update_many(
where=self._unprocessed_snapshot(job),
data=update_data,
)
verbose_proxy_logger.info(
f"CheckBatchCost: marked job {job.id} as {response.status} in DB"
)
if finalized == 0:
return False
verbose_proxy_logger.info(f"CheckBatchCost: marked job {job.id} as {response.status} in DB")
return True
except Exception as db_err:
verbose_proxy_logger.error(
@ -619,7 +654,11 @@ class CheckBatchCost:
from litellm.files.types import FileContentCallOptions
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.litellm_core_utils.litellm_logging import deployment_pricing_model_info, mask_api_base_credentials
from litellm.litellm_core_utils.litellm_logging import (
deployment_pricing_model_info,
mask_api_base_credentials,
resolve_vertex_location_for_cost,
)
from litellm.proxy.openai_files_endpoints.common_utils import (
_is_base64_encoded_unified_file_id,
)
@ -748,6 +787,12 @@ class CheckBatchCost:
custom_llm_provider=batch_file_provider,
model_name=model_name,
model_info=deployment_model_info,
vertex_location=resolve_vertex_location_for_cost(
custom_llm_provider=llm_provider,
litellm_params=deployment_info.litellm_params.model_dump(exclude_none=True),
optional_params=None,
model=model_name,
),
)
error_file_failed_requests: Final = await count_error_file_failed_requests(
response,
@ -889,7 +934,10 @@ class CheckBatchCost:
)
from litellm.batches.batch_utils import fetch_batch_managed_file_content
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.litellm_core_utils.litellm_logging import deployment_pricing_model_info
from litellm.litellm_core_utils.litellm_logging import (
deployment_pricing_model_info,
resolve_vertex_location_for_cost,
)
try:
deployment_info: Final = self.llm_router.get_deployment(model_id=model_id)
@ -933,6 +981,12 @@ class CheckBatchCost:
cost_provider=cost_provider,
model_info=deployment_pricing_model_info(model_id=model_id, deployment_model=litellm_model_name),
metadata=line_item_metadata(await self._creator_attribution.batch_metadata(job, batch_id)),
vertex_location=resolve_vertex_location_for_cost(
custom_llm_provider=llm_provider,
litellm_params=deployment_info.litellm_params.model_dump(exclude_none=True),
optional_params=None,
model=model_name,
),
)
input_content: Final = (
b""
@ -1028,6 +1082,7 @@ class CheckBatchCost:
"""
try:
from litellm.integrations.prometheus import PrometheusLogger
prom_logger = PrometheusLogger.get_instance()
except Exception as e:
verbose_proxy_logger.error(f"CheckBatchCost: could not get Prometheus logger: {e}")
@ -1052,7 +1107,7 @@ class CheckBatchCost:
# endpoint may transition a batch to "complete" before
# CheckBatchCost runs. The batch_processed=False filter
# already prevents reprocessing finished batches.
jobs = await _managed_object_table(self.prisma_client).find_many(
jobs = await self._find_jobs(
where={
"file_purpose": "batch",
"batch_processed": False,
@ -1065,8 +1120,6 @@ class CheckBatchCost:
]
},
},
take=MAX_OBJECTS_PER_POLL_CYCLE,
order={"created_at": "asc"},
)
self.batch_processed_support_confirmed = True
except Exception as query_err:

View file

@ -59,6 +59,19 @@ if TYPE_CHECKING:
from litellm.router import Router
_STORED_RESPONSE_ADAPTER: Final[TypeAdapter[dict[str, object]]] = TypeAdapter(dict[str, object])
class _RoutedModelInfo(BaseModel):
model_config = ConfigDict(frozen=True)
id: str | None = None
class _RoutedDeployment(BaseModel):
model_config = ConfigDict(frozen=True)
model_info: _RoutedModelInfo = _RoutedModelInfo()
_ROUTED_DEPLOYMENTS: Final = TypeAdapter(tuple[_RoutedDeployment, ...])
_RETRIEVE_RESPONSE_CALL_TYPE: Final = "aget_responses"
@ -314,10 +327,23 @@ class CheckResponsesCost:
INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
**target.model_metadata,
**(_deployment_metadata(deployment) if deployment is not None else {}),
**self._requested_model_group(target, job.team_id),
}
),
)
def _requested_model_group(self, target: _PollTarget, team_id: str | None) -> Mapping[str, object]:
requested: Final = target.model_metadata.get("model_group")
if target.deployment is None or not isinstance(requested, str):
return MappingProxyType({})
routed: Final = _ROUTED_DEPLOYMENTS.validate_python(
self.llm_router.get_model_list(model_name=requested, team_id=team_id) or ()
)
serving_id: Final = target.deployment.model_info.id
return MappingProxyType(
{"model_group": requested} if any(row.model_info.id == serving_id for row in routed) else {}
)
async def _bill_once(
self,
job: ManagedObjectRow,

View file

@ -9,6 +9,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Final, Protocol, cast
from pydantic import TypeAdapter
@ -27,6 +28,12 @@ if TYPE_CHECKING:
class ManagedObjectRow(Protocol):
@property
def created_at(self) -> datetime: ...
@property
def updated_at(self) -> datetime: ...
@property
def id(self) -> str: ...
@ -107,8 +114,10 @@ class ManagedObjectCreatorAttribution:
user's alias, because a resolvable name is more useful on the spend row than a null.
user_api_key_org_id is resolved too: the spend update writer reads it off this metadata
to increment organization spend, so leaving it out drops the cost from org accounting.
A background response also carries its end user and the key, team, and user budgets, the
way the read that billed it before did, and never names a key-scoped creator as its user.
A background response also carries its end user, the creating key's project and its alias,
and the key, team, and user budgets and per-model budgets, the way the read that billed it
before did, and never names a key-scoped creator as its user. A key that sets no budget of
its own takes its budget tier's, the same rule auth applies.
"""
def __init__(self, prisma_client: PrismaClient, poller_name: str) -> None:
@ -126,9 +135,23 @@ class ManagedObjectCreatorAttribution:
"user_api_key_user_id": _creator_user_id(job.created_by),
**_shared_metadata(job, rows),
**({"user_api_key_end_user_id": end_user_id} if end_user_id else {}),
**_budget_fields(rows.key, "user_api_key_spend", "user_api_key_max_budget"),
**_budget_fields(rows.team, "user_api_key_team_spend", "user_api_key_team_max_budget"),
**_budget_fields(rows.user, "user_api_key_user_spend", "user_api_key_user_max_budget"),
"user_api_key_project_id": getattr(rows.key, "project_id", None),
"user_api_key_project_alias": getattr(
getattr(rows.key, "litellm_project_table", None), "project_alias", None
),
**_key_budget_fields(rows.key),
**_budget_fields(
rows.team,
"user_api_key_team_spend",
"user_api_key_team_max_budget",
"user_api_key_team_model_max_budget",
),
**_budget_fields(
rows.user,
"user_api_key_user_spend",
"user_api_key_user_max_budget",
"user_api_key_user_model_max_budget",
),
}
async def _rows(self, job: ManagedObjectRow, object_id: str) -> _CreatorRows:
@ -155,7 +178,9 @@ class ManagedObjectCreatorAttribution:
if not api_key or _is_cli_session_key(api_key, created_by):
return None
try:
return await _token_table(self._prisma_client).find_unique(where={"token": api_key})
return await _token_table(self._prisma_client).find_unique(
where={"token": api_key}, include={"litellm_budget_table": True, "litellm_project_table": True}
)
except Exception as e:
verbose_proxy_logger.error(f"{self._poller_name}: could not look up the creating key for {object_id}: {e}")
return None
@ -212,7 +237,28 @@ def _organization_id(row: object) -> str | None:
return cast("str | None", getattr(row, "organization_id", None)) if row is not None else None
def _budget_fields(row: object, spend_key: str, max_budget_key: str) -> Mapping[str, object]:
def _budget_fields(row: object, spend_key: str, max_budget_key: str, model_max_budget_key: str) -> Mapping[str, object]:
if row is None:
return {}
return {spend_key: getattr(row, "spend", None), max_budget_key: getattr(row, "max_budget", None)}
return {
spend_key: getattr(row, "spend", None),
max_budget_key: getattr(row, "max_budget", None),
model_max_budget_key: getattr(row, "model_max_budget", None),
}
def _key_budget_fields(key: object) -> Mapping[str, object]:
if key is None:
return {}
tier: Final = getattr(key, "litellm_budget_table", None)
return {
"user_api_key_spend": getattr(key, "spend", None),
"user_api_key_max_budget": _own_or_tier_budget(key, tier, "max_budget"),
"user_api_key_model_max_budget": _own_or_tier_budget(key, tier, "model_max_budget"),
}
def _own_or_tier_budget(key: object, tier: object, field: str) -> object:
own: Final = getattr(key, field, None)
tier_value: Final = getattr(tier, field, None)
return own if tier_value is None or own not in (None, {}) else tier_value

View file

@ -6,6 +6,7 @@ import base64
import json
import time
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from itertools import chain
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
@ -479,6 +480,13 @@ def _with_managed_background_marker(request: Mapping[str, object]) -> dict[str,
_MANAGED_FILES_TARGET: Final = "managed_files"
_PROVIDER_FILE_ID_KWARGS: Final = MappingProxyType(
{
CallTypes.acreate_batch: ("input_file_id",),
CallTypes.acreate_fine_tuning_job: ("training_file", "validation_file"),
}
)
_PROVIDER_FILE_ID_KEYS: Final = tuple(dict.fromkeys(chain.from_iterable(_PROVIDER_FILE_ID_KWARGS.values())))
_PROVIDER_FILE_RETRIEVE_RETRY_DELAYS_SECONDS: Final = (0.5, 1.0, 2.0)
@ -1562,6 +1570,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
if isinstance(file_id, str) and file_id
)
await self.check_file_ids_access(fine_tuning_file_ids, user_api_key_dict)
if fine_tuning_file_ids:
data["model_file_id_mapping"] = await self.get_model_file_id_mapping(
fine_tuning_file_ids, user_api_key_dict.parent_otel_span
)
return data
@ -1576,24 +1588,27 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
if request_kwargs is None:
return healthy_deployments
input_file_id = cast(Optional[str], request_kwargs.get("input_file_id"))
model_file_id_mapping = cast(
Optional[Dict[str, Dict[str, str]]],
request_kwargs.get("model_file_id_mapping"),
request: Final = cast(Mapping[str, object], request_kwargs)
model_file_id_mapping: Final = cast(Mapping[str, Mapping[str, str]], request.get("model_file_id_mapping") or {})
requested_file_ids: Final = (request.get(key) for key in _PROVIDER_FILE_ID_KEYS)
model_ids_per_file: Final = tuple(
frozenset(model_file_id_mapping[file_id])
for file_id in requested_file_ids
if isinstance(file_id, str) and file_id in model_file_id_mapping
)
allowed_model_ids: Final[frozenset[str]] = (
model_ids_per_file[0].intersection(*model_ids_per_file[1:]) if model_ids_per_file else frozenset()
)
allowed_model_ids = []
if input_file_id and model_file_id_mapping:
model_id_dict = model_file_id_mapping.get(input_file_id, {})
allowed_model_ids = list(model_id_dict.keys())
if len(allowed_model_ids) == 0:
return healthy_deployments
return [
holders: Final[list[dict[str, object]]] = [
deployment
for deployment in healthy_deployments
if deployment.get("model_info", {}).get("id") in allowed_model_ids
]
return holders if holders or "input_file_id" in request else healthy_deployments
async def async_pre_call_deployment_hook(
self, kwargs: Dict[str, Any], call_type: Optional[CallTypes]
@ -1601,25 +1616,21 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
"""
Allow modifying the request just before it's sent to the deployment.
"""
accessor_key: Optional[str] = None
if call_type and call_type == CallTypes.acreate_batch:
accessor_key = "input_file_id"
elif call_type and call_type == CallTypes.acreate_fine_tuning_job:
accessor_key = "training_file"
else:
accessor_keys: Final = _PROVIDER_FILE_ID_KWARGS.get(call_type) if call_type else None
if accessor_keys is None:
return kwargs
if accessor_key:
model_file_id_mapping = cast(Optional[Dict[str, Dict[str, str]]], kwargs.get("model_file_id_mapping"))
# model_info may be at top-level or nested under litellm_metadata
# (batch/file operations use litellm_metadata)
model_id = cast(Optional[str], kwargs.get("model_info", {}).get("id", None))
if model_id is None:
model_id = cast(
Optional[str],
kwargs.get("litellm_metadata", {}).get("model_info", {}).get("id", None),
)
for accessor_key in accessor_keys:
input_file_id = cast(Optional[str], kwargs.get(accessor_key))
model_file_id_mapping = cast(Optional[Dict[str, Dict[str, str]]], kwargs.get("model_file_id_mapping"))
# model_info may be at top-level or nested under litellm_metadata
# (batch/file operations use litellm_metadata)
model_id = cast(Optional[str], kwargs.get("model_info", {}).get("id", None))
if model_id is None:
model_id = cast(
Optional[str],
kwargs.get("litellm_metadata", {}).get("model_info", {}).get("id", None),
)
mapped_file_id: Optional[str] = None
if input_file_id and model_file_id_mapping and model_id:
mapped_file_id = model_file_id_mapping.get(input_file_id, {}).get(model_id, None)
@ -1813,7 +1824,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
),
)
async def get_model_file_id_mapping(self, file_ids: List[str], litellm_parent_otel_span: Span) -> dict:
async def get_model_file_id_mapping(self, file_ids: Sequence[str], litellm_parent_otel_span: Span) -> dict:
"""
Get model-specific file IDs for a list of proxy file IDs.
Returns a dictionary mapping litellm_proxy/ file_id -> model_id -> model_file_id

View file

@ -354,6 +354,7 @@ llm_guard_mode: Literal["all", "key-specific", "request-specific"] = "all"
guardrail_name_config_map: Dict[str, GuardrailItem] = {}
include_cost_in_streaming_usage: bool = False
reasoning_auto_summary: bool = False
reasoning_effort_rounding: Literal["up", "down"] = "up"
### PROMPTS ####
from litellm.types.prompts.init_prompts import PromptSpec
@ -550,6 +551,7 @@ from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map, ma
model_cost = get_model_cost_map(url=model_cost_map_url)
cost_discount_config: Dict[str, float] = {} # Provider-specific cost discounts {"vertex_ai": 0.05} = 5% discount
apply_search_free_tier: bool = False
cost_margin_config: Dict[
str, Union[float, Dict[str, float]]
] = {} # Provider-specific or global cost margins. Examples:
@ -2384,6 +2386,7 @@ _AGENT_EXPORTS: Final = frozenset(
"ClaudeCodeOptions",
"CodexOptions",
"OpenCodeOptions",
"PiOptions",
"DeepAgentsOptions",
"ToolLoopOptions",
}

View file

@ -80,7 +80,7 @@ class A2ACompletionBridgeHandler:
@staticmethod
def _build_completion_params(
params: dict[str, Any],
litellm_params: Mapping[str, Any],
litellm_params: Mapping[str, object],
api_base: str | None,
agent_extra_headers: Mapping[str, str] | None,
*,

View file

@ -602,9 +602,9 @@ def _build_streaming_logging_obj(
litellm_params: dict[str, object] | None,
metadata: dict[str, object] | None,
proxy_server_request: dict[str, object] | None,
start_time: datetime.datetime,
) -> Logging:
"""Build logging object for streaming A2A requests."""
start_time: Final = datetime.datetime.now()
model: Final = f"a2a_agent/{agent_name}"
logging_obj: Final = Logging(
@ -718,6 +718,7 @@ async def asend_message_streaming(
if request is None:
raise ValueError("request is required")
called_at: Final = datetime.datetime.now()
_raw_logging_obj: Final = kwargs.get("litellm_logging_obj")
logging_obj: Logging | None = _raw_logging_obj if isinstance(_raw_logging_obj, Logging) else None
@ -751,6 +752,7 @@ async def asend_message_streaming(
litellm_params=litellm_params,
metadata=metadata,
proxy_server_request=proxy_server_request,
start_time=called_at,
)
verbose_logger.info("A2A send_message_streaming request_id=%s, agent=%s", request.id, agent_name)

View file

@ -41,7 +41,7 @@ class A2AStreamingIterator:
self.request = request
self.logging_obj = logging_obj
self.agent_name = agent_name
self.start_time = datetime.now()
self.start_time = logging_obj.start_time
# Collect chunks for token counting
self.chunks: list[SendStreamingMessageResponse] = []
@ -54,6 +54,8 @@ class A2AStreamingIterator:
async def __anext__(self) -> "SendStreamingMessageResponse":
try:
chunk: Final = await self.stream.__anext__()
if self.logging_obj.completion_start_time is None:
self.logging_obj.update_completion_start_time(completion_start_time=datetime.now())
# Store chunk
self.chunks.append(chunk)

View file

@ -38,6 +38,7 @@ class BatchLineItemSource:
cost_provider: BatchFileProvider
model_info: ModelInfo | None
metadata: StandardBatchLineItemMetadata
vertex_location: str | None = None
def line_item_metadata(attribution: Mapping[str, object]) -> StandardBatchLineItemMetadata:
@ -140,7 +141,11 @@ def _line_item(
source: BatchLineItemSource, custom_id: str | None, request: Mapping[str, object] | None, entry: dict[str, object]
) -> StandardBatchLineItem:
priced: Final = batch_cost_and_usage(
(entry,), source.cost_provider, model_name=source.model, model_info=source.model_info
(entry,),
source.cost_provider,
model_name=source.model,
model_info=source.model_info,
vertex_location=source.vertex_location,
)
succeeded: Final = priced.successful_requests > 0
return StandardBatchLineItem(

View file

@ -77,9 +77,10 @@ async def calculate_batch_cost_and_usage(
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "mistral"],
model_name: str | None = None,
model_info: ModelInfo | None = None,
vertex_location: str | None = None,
) -> BatchCostUsageResult:
"""Calculate the cost and usage of a batch, see batch_cost_and_usage."""
return batch_cost_and_usage(file_content_dictionary, custom_llm_provider, model_name, model_info)
return batch_cost_and_usage(file_content_dictionary, custom_llm_provider, model_name, model_info, vertex_location)
def batch_cost_and_usage(
@ -87,6 +88,7 @@ def batch_cost_and_usage(
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "mistral"],
model_name: str | None = None,
model_info: ModelInfo | None = None,
vertex_location: str | None = None,
) -> BatchCostUsageResult:
"""
Calculate the cost and usage of a batch.
@ -96,16 +98,21 @@ def batch_cost_and_usage(
pricing. Threaded through to batch_cost_calculator so that
deployment-specific pricing (e.g. input_cost_per_token_batches)
is used instead of the global cost map.
vertex_location: The Vertex AI location the batch ran on; non-global
locations apply the model's regional-endpoint uplift multiplier.
"""
first_row: Final = file_content_dictionary[0] if file_content_dictionary else None
if _uses_native_vertex_output(custom_llm_provider, model_name, first_row):
return calculate_vertex_ai_batch_cost_and_usage(file_content_dictionary, model_name, model_info=model_info)
return calculate_vertex_ai_batch_cost_and_usage(
file_content_dictionary, model_name, model_info=model_info, vertex_location=vertex_location
)
return _aggregate_batch_cost_usage_models(
entries=file_content_dictionary,
custom_llm_provider=custom_llm_provider,
model_name=model_name,
model_info=model_info,
vertex_location=vertex_location,
)
@ -115,6 +122,7 @@ async def handle_completed_batch(
model_name: str | None = None,
litellm_params: dict[str, object] | None = None,
model_info: ModelInfo | None = None,
vertex_location: str | None = None,
) -> BatchCostUsageResult:
"""Fetch a completed batch's output file and aggregate its cost, usage, and
models in a single pass over the JSONL lines, so the parsed file content is
@ -128,6 +136,8 @@ async def handle_completed_batch(
model_info: Optional deployment-level model info with custom pricing,
threaded through so a deployment's configured rates win over the
global cost map.
vertex_location: The Vertex AI location the batch ran on; non-global
locations apply the model's regional-endpoint uplift multiplier.
"""
# A completed batch whose request lines all failed has no output file - the
# results are written to a separate error_file_id and output_file_id is None.
@ -155,7 +165,10 @@ async def handle_completed_batch(
output_file_result: Final = (
calculate_vertex_ai_batch_cost_and_usage(
_iter_batch_output_entries(file_content), model_name, model_info=model_info
_iter_batch_output_entries(file_content),
model_name,
model_info=model_info,
vertex_location=vertex_location,
)
if _uses_native_vertex_output(
custom_llm_provider, model_name, next(_iter_batch_output_entries(file_content), None)
@ -165,6 +178,7 @@ async def handle_completed_batch(
custom_llm_provider=custom_llm_provider,
model_name=model_name,
model_info=model_info,
vertex_location=vertex_location,
)
)
@ -203,6 +217,7 @@ def _classify_output_line_stats(
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock", "mistral"],
model_name: str | None,
model_info: ModelInfo | None,
vertex_location: str | None = None,
) -> Iterator[_BatchOutputLineStats | _LineOutcome]:
"""Classify every output line in a single pass, so counting failures never needs
a second read of a potentially huge output file. A line the provider reported as
@ -213,7 +228,7 @@ def _classify_output_line_stats(
if not _batch_response_was_successful(entry, custom_llm_provider):
yield _LineOutcome.PROVIDER_FAILED
continue
stats = _safe_output_line_stats(entry, custom_llm_provider, model_name, model_info)
stats = _safe_output_line_stats(entry, custom_llm_provider, model_name, model_info, vertex_location)
yield stats if stats is not None else _LineOutcome.UNCOSTABLE
@ -222,13 +237,14 @@ def _safe_output_line_stats(
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock", "mistral"],
model_name: str | None,
model_info: ModelInfo | None,
vertex_location: str | None = None,
) -> _BatchOutputLineStats | None:
"""Return the stats for one provider-successful batch output line, or None when
it cannot be costed, so a single bad line never aborts the whole batch's cost
accounting."""
custom_id: Final = entry.get("custom_id") if isinstance(entry, dict) else None
try:
return _compute_output_line_stats(entry, custom_llm_provider, model_name, model_info)
return _compute_output_line_stats(entry, custom_llm_provider, model_name, model_info, vertex_location)
except Exception as e: # noqa: BLE001 # any single line's costing failure must not abort the whole batch
verbose_logger.warning(
"batch output line could not be costed, so it is billed at $0 and the rest of the batch "
@ -244,6 +260,7 @@ def _compute_output_line_stats(
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock", "mistral"],
model_name: str | None,
model_info: ModelInfo | None,
vertex_location: str | None = None,
) -> _BatchOutputLineStats:
response_body: Final = _get_response_from_batch_job_output_file(entry, custom_llm_provider)
usage: Final = _get_batch_job_usage_from_response_body(response_body, custom_llm_provider)
@ -258,6 +275,7 @@ def _compute_output_line_stats(
model_name=model_name,
response_model=response_model,
model_info=model_info,
vertex_location=vertex_location,
)
return _BatchOutputLineStats(
prompt_cost=line_prompt_cost,
@ -287,6 +305,7 @@ def _output_line_cost(
model_name: str | None,
response_model: str | None,
model_info: ModelInfo | None,
vertex_location: str | None = None,
) -> tuple[float, float]:
"""(prompt_cost, completion_cost) for one output line, priced at batch rates."""
from litellm.cost_calculator import batch_cost_calculator, ocr_batch_cost
@ -307,6 +326,7 @@ def _output_line_cost(
model=cost_model,
custom_llm_provider=custom_llm_provider,
model_info=model_info,
vertex_location=vertex_location,
)
@ -315,11 +335,14 @@ def _aggregate_batch_cost_usage_models(
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock", "mistral"],
model_name: str | None = None,
model_info: ModelInfo | None = None,
vertex_location: str | None = None,
) -> BatchCostUsageResult:
"""Aggregate cost, usage, models, and pass/fail counts from batch output
entries in a single pass, holding one small stats record per line instead
of the parsed file."""
all_results: Final = tuple(_classify_output_line_stats(entries, custom_llm_provider, model_name, model_info))
all_results: Final = tuple(
_classify_output_line_stats(entries, custom_llm_provider, model_name, model_info, vertex_location)
)
line_stats: Final = tuple(result for result in all_results if isinstance(result, _BatchOutputLineStats))
failed_requests: Final = sum(1 for result in all_results if result is _LineOutcome.PROVIDER_FAILED)
successful_requests: Final = len(all_results) - failed_requests
@ -366,6 +389,7 @@ def calculate_vertex_ai_batch_cost_and_usage(
vertex_ai_batch_responses: Iterable[dict[str, object]],
model_name: str | None = None,
model_info: ModelInfo | None = None,
vertex_location: str | None = None,
) -> BatchCostUsageResult:
"""
Cost and usage of a native Vertex predictions.jsonl, one
@ -384,6 +408,7 @@ def calculate_vertex_ai_batch_cost_and_usage(
model_info=model_info,
calculate_usage=VertexGeminiConfig.calculate_usage,
cost_calculator=batch_cost_calculator,
vertex_location=vertex_location,
)
for row in vertex_ai_batch_responses
)

View file

@ -59,16 +59,6 @@ class PendingBatchRead:
previous_access_times: dict[str, float | None]
@dataclass(frozen=True, slots=True)
class DeclaredBatchRead:
"""A ``async_batch_get_cache`` split in two: the memory half done, the Redis half declared on a ``RedisBatch``
so it rides that batch's next round trip, resolved later with ``async_resolve_batch_get``."""
keys: tuple[str, ...]
pending: PendingBatchRead
result: BatchResult[Mapping[str, object]] | None
def _log_deferred_increment_failure(future: asyncio.Future[float]) -> None:
if future.cancelled():
return
@ -382,22 +372,6 @@ class DualCache(BaseCache):
await self.in_memory_cache.async_set_cache(key, value, **self._backfill_kwargs(kwargs))
return merged
async def declare_batch_get(self, keys: Sequence[str], batch: RedisBatch) -> DeclaredBatchRead:
pending: Final = await self._prepare_batch_get(
list(keys),
local_only=False,
throttle_redis=False,
)
return DeclaredBatchRead(
keys=tuple(keys),
pending=pending,
result=batch.mget(pending.redis_keys) if pending.redis_keys else None,
)
async def async_resolve_batch_get(self, declared: DeclaredBatchRead) -> list[object | None]:
redis_result: Final = None if declared.result is None else await declared.result
return await self._apply_batch_get(declared.pending, redis_result)
async def async_batch_get_cache(
self,
keys: list,

View file

@ -134,7 +134,12 @@ _INCREMENT_WITH_FLOOR_LUA: Final = (
"return count"
)
_INCREMENT_IF_PRESENT_LUA: Final = (
"if redis.call('EXISTS', KEYS[1]) == 0 then return false end return redis.call('INCRBYFLOAT', KEYS[1], ARGV[1])"
)
_LUA_COUNT: Final = TypeAdapter(int)
_LUA_OPTIONAL_TOTAL: Final[TypeAdapter[float | None]] = TypeAdapter(float | None)
_OPTIONAL_COUNTS: Final = TypeAdapter(tuple[int | None, ...])
@ -675,7 +680,7 @@ class RedisCache(BaseCache):
# redis namespaces
self.namespace = namespace
# for high traffic, we store the redis results in memory and then batch write to redis
self.redis_batch_writing_buffer: list = []
self.redis_batch_writing_buffer: list[tuple[str, object]] = []
if redis_flush_size is None:
self.redis_flush_size: int = 100
else:
@ -1568,6 +1573,46 @@ class RedisCache(BaseCache):
count: Final = await _redis_client.eval(_INCREMENT_WITH_FLOOR_LUA, 1, namespaced_key, value, ttl)
return _LUA_COUNT.validate_python(count)
@_redis_circuit_breaker_guard
async def async_increment_if_present(
self, key: str, value: float, parent_otel_span: Span | None = None
) -> float | None:
_redis_client: Final = self._async_commands()
namespaced_key: Final = self.check_and_fix_namespace(key=key)
start_time: Final = time.time()
try:
total: Final = _LUA_OPTIONAL_TOTAL.validate_python(
await _redis_client.eval(_INCREMENT_IF_PRESENT_LUA, 1, namespaced_key, value)
)
except Exception as e:
failed_at: Final = time.time()
asyncio.create_task(
self.service_logger_obj.async_service_failure_hook(
service=ServiceTypes.REDIS,
duration=failed_at - start_time,
error=e,
call_type="async_increment_if_present",
caller=_get_call_stack_info(),
start_time=start_time,
end_time=failed_at,
parent_otel_span=parent_otel_span,
)
)
raise
end_time: Final = time.time()
asyncio.create_task(
self.service_logger_obj.async_service_success_hook(
service=ServiceTypes.REDIS,
duration=end_time - start_time,
call_type="async_increment_if_present",
caller=_get_call_stack_info(),
start_time=start_time,
end_time=end_time,
parent_otel_span=parent_otel_span,
)
)
return total
async def flush_cache_buffer(self):
print_verbose(f"flushing to redis....reached size of buffer {len(self.redis_batch_writing_buffer)}")
await self.async_set_cache_pipeline(self.redis_batch_writing_buffer)

View file

@ -191,6 +191,11 @@ DEFAULT_IMAGE_HEIGHT: Final = get_env_int("DEFAULT_IMAGE_HEIGHT", 300)
# Maps to OpenAI's 50 MB payload limit - requests with images exceeding this size will be rejected
# Set MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0 to disable image URL handling entirely
MAX_IMAGE_URL_DOWNLOAD_SIZE_MB: Final = get_env_float("MAX_IMAGE_URL_DOWNLOAD_SIZE_MB", 50.0)
MAX_DECOMPRESSED_REQUEST_BODY_SIZE_MB: Final = get_env_float("MAX_DECOMPRESSED_REQUEST_BODY_SIZE_MB", 50.0)
MAX_REQUEST_BODY_DECOMPRESSION_RATIO: Final = get_env_int("MAX_REQUEST_BODY_DECOMPRESSION_RATIO", 200)
MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB: Final = get_env_float(
"MAX_DECOMPRESSED_REQUEST_BODIES_IN_FLIGHT_MB", 200.0
)
MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB: Final = get_env_int(
"MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB", 1024
) # 1MB = 1024KB
@ -252,6 +257,13 @@ MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS: Final = get_env_int("MCP_PER_USER_TOKE
# MCP timeout defaults (seconds). Override via env vars for slow/custom MCP servers.
MCP_CLIENT_TIMEOUT: Final = get_env_float("LITELLM_MCP_CLIENT_TIMEOUT", 60.0)
MCP_TOOL_LISTING_TIMEOUT: Final = get_env_float("LITELLM_MCP_TOOL_LISTING_TIMEOUT", 30.0)
MCP_UPSTREAM_RATE_LIMIT_MAX_RETRIES: Final = get_env_int("LITELLM_MCP_UPSTREAM_RATE_LIMIT_MAX_RETRIES", 3)
MCP_UPSTREAM_RATE_LIMIT_BASE_DELAY_SECONDS: Final = get_env_float(
"LITELLM_MCP_UPSTREAM_RATE_LIMIT_BASE_DELAY_SECONDS", 0.5
)
MCP_UPSTREAM_RATE_LIMIT_MAX_DELAY_SECONDS: Final = get_env_float(
"LITELLM_MCP_UPSTREAM_RATE_LIMIT_MAX_DELAY_SECONDS", 5.0
)
MCP_METADATA_TIMEOUT: Final = get_env_float("LITELLM_MCP_METADATA_TIMEOUT", 10.0)
MCP_HEALTH_CHECK_TIMEOUT: Final = get_env_float("LITELLM_MCP_HEALTH_CHECK_TIMEOUT", 10.0)
AGENT_HEALTH_CHECK_TIMEOUT_SECONDS: Final = get_env_float("LITELLM_AGENT_HEALTH_CHECK_TIMEOUT", 5.0)
@ -1042,7 +1054,7 @@ openai_compatible_endpoints: Final[list[str]] = [
]
openai_compatible_providers: Final[list] = [
openai_compatible_providers: Final[list[str]] = [
"anyscale",
"groq",
"nvidia_nim",
@ -1633,6 +1645,7 @@ OUTPUT_TOKEN_CEILING_PARAMS: Final = frozenset({"max_tokens", "max_completion_to
CLIENT_OUTPUT_CEILING_METADATA_KEY: Final = "_client_output_ceiling"
CONSUMED_REQUEST_TAGS_METADATA_KEY: Final = "_consumed_request_tags"
ROUTING_REQUEST_TAGS_METADATA_KEY: Final = "_routing_request_tags"
BAGGAGE_MAPPED_TAGS_METADATA_KEY: Final = "_baggage_mapped_tags"
VECTOR_STORE_CALLER_METADATA_KWARG: Final = "litellm_vector_store_caller_metadata"
ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY: Final = "_litellm_router_usage_counted_tokens"
INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
@ -1842,6 +1855,9 @@ SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS: Final = get_env_float(
SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS: Final = get_env_float("SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS", 300.0)
SPEND_LOG_CLEANUP_BATCH_TIMEOUT_SECONDS: Final = get_env_float("SPEND_LOG_CLEANUP_BATCH_TIMEOUT_SECONDS", 30.0)
SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP: Final = get_env_int("SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP", 100000)
SPEND_LOG_SIZE_CHECK_JOB_NAME: Final = "spend_log_size_check"
SPEND_LOG_SIZE_CHECK_INTERVAL: Final = os.getenv("SPEND_LOG_SIZE_CHECK_INTERVAL", "1d")
SPEND_LOG_SIZE_CLEANUP_RECHECK_BATCHES: Final = get_env_int("SPEND_LOG_SIZE_CLEANUP_RECHECK_BATCHES", 10)
SCHEDULED_JOB_SHUTDOWN_FINISH_TIMEOUT_SECONDS: Final = get_env_float(
"SCHEDULED_JOB_SHUTDOWN_FINISH_TIMEOUT_SECONDS", 5.0
)
@ -1963,6 +1979,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES: Final = [
"public_model_groups",
"public_model_groups_links",
"cost_discount_config",
"apply_search_free_tier",
"cost_margin_config",
"block_requests_for_models_without_pricing",
"budget_exceeded_throttle_percentage",
@ -2303,6 +2320,7 @@ HARNESS_STDERR_TAIL_LINES: Final = 40
HARNESS_STREAM_READ_CHUNK_BYTES: Final = 64 * 1024
HARNESS_EVENT_QUEUE_MAX_SIZE: Final = 1024
HARNESS_PROCESS_KILL_GRACE_SECONDS: Final = 5.0
HARNESS_RESUME_COMMAND_TIMEOUT_SECONDS: Final = 30.0
HARNESS_SNAPSHOT_SKIP_DIRS: Final = frozenset(
{
".git",

View file

@ -45,6 +45,7 @@ from litellm.litellm_core_utils.llm_cost_calc.utils import (
get_regional_uplift_multiplier,
get_service_tier_cost_key,
get_token_type_cost_breakdown,
get_vertex_regional_endpoint_uplift,
parse_prompt_tokens_details,
select_cost_metric_for_model,
)
@ -137,6 +138,7 @@ from litellm.types.utils import (
TranscriptionUsageTokensObject,
Usage,
VectorStoreSearchResponse,
is_server_derived_pricing_key,
)
from litellm.utils import (
CallTypes,
@ -149,6 +151,8 @@ from litellm.utils import (
TextCompletionResponse,
TranscriptionResponse,
cached_get_model_info_helper,
model_info_with_pricing_overrides,
runtime_model_cost_registrations,
token_counter,
unmapped_model_generalization,
)
@ -437,6 +441,7 @@ def cost_per_token(
### REQUEST MODEL ###
request_model: str | None = None, # original request model for router detection
custom_model_info: OCRPricing | None = None,
bedrock_model_info: ModelInfo | None = None,
) -> tuple[float, float]:
"""
Calculates the cost per token for a given model, prompt tokens, and completion tokens.
@ -676,6 +681,7 @@ def cost_per_token(
model=model,
custom_llm_provider=custom_llm_provider,
data_residency=data_residency,
vertex_location=vertex_location,
)
elif call_type == "atranscription" or call_type == "transcription":
if _transcription_usage_has_token_details(usage_block):
@ -738,6 +744,15 @@ def cost_per_token(
elif custom_llm_provider == "anthropic":
return anthropic_cost_per_token(model=model, usage=usage_block, service_tier=service_tier)
elif custom_llm_provider == "bedrock":
resolved_bedrock_info: Final = bedrock_model_info or _bedrock_pricing_info(model, None, "bedrock", False)
if resolved_bedrock_info is not None:
return generic_cost_per_token(
model=model,
usage=usage_block,
custom_llm_provider="bedrock",
service_tier=service_tier,
model_info=resolved_bedrock_info,
)
return bedrock_cost_per_token(model=model, usage=usage_block, service_tier=service_tier)
elif custom_llm_provider == "openai":
return openai_cost_per_token(
@ -860,11 +875,46 @@ def _cost_map_entry_prices_anything(entry: Mapping[str, object]) -> bool:
)
def _declares_rates(model_info: ModelInfo | None) -> bool:
entry: Final = _raw_cost_map_entry(model_info["key"]) if model_info is not None else None
def _bedrock_registration_has_prices(entry: Mapping[str, object]) -> bool:
return _cost_map_entry_prices_anything(entry) or any(
value is not None and field.startswith("cache_") and is_server_derived_pricing_key(field)
for field, value in entry.items()
)
def _bedrock_pricing_info(
model: str | None,
invoked_model_id: str | None,
custom_llm_provider: str | None,
explicit_pricing: bool,
) -> ModelInfo | None:
if explicit_pricing or custom_llm_provider != "bedrock" or not model:
return None
registration: Final = next(
(
(key, entry)
for key, entry in runtime_model_cost_registrations(model, "bedrock")
if _bedrock_registration_has_prices(entry)
),
None,
)
profile: Final = _cost_map_model_info(invoked_model_id, "bedrock") if invoked_model_id else None
if profile is None or not _declares_rates(profile):
return _cost_map_model_info(registration[0], None) if registration is not None else None
if registration is None or registration[0] == profile["key"]:
return profile
return model_info_with_pricing_overrides(profile, registration[1])
def cost_map_key_prices_anything(key: str) -> bool:
entry: Final = _raw_cost_map_entry(key)
return entry is not None and _cost_map_entry_prices_anything(entry)
def _declares_rates(model_info: ModelInfo | None) -> bool:
return model_info is not None and cost_map_key_prices_anything(model_info["key"])
def _base_model_pricing_provider(base_model: str, custom_llm_provider: str | None) -> str | None:
"""The provider to price an explicit base_model under, when the deployment's own provider has
no rates for it (e.g. chatgpt/ subscription rows) but the base model's own row does"""
@ -886,6 +936,16 @@ def _pricing_provider_for_cost_candidate(
return current_provider if isinstance(current_provider, str) else None
def _invoked_model_id_for_cost(
optional_params: object,
logging_obj: LitellmLoggingObject | None,
) -> str | None:
recorded: Final[object] = getattr(logging_obj, "model_call_details", None)
return _get_hidden_str_for_cost_calc(optional_params, "model_id") or _get_hidden_str_for_cost_calc(
recorded, "model_id"
)
def select_model_name_for_cost_calc(
model: str | None,
completion_response: object | None,
@ -894,6 +954,7 @@ def select_model_name_for_cost_calc(
custom_llm_provider: str | None = None,
router_model_id: str | None = None,
region_name: str | None = None,
invoked_model_id: str | None = None,
) -> str | None:
"""
1. If custom pricing is true, return received model name
@ -914,6 +975,13 @@ def select_model_name_for_cost_calc(
hidden_params: Final[dict | None] = getattr(completion_response, "_hidden_params", None)
provider_response_model: Final = _get_hidden_str_for_cost_calc(hidden_params, "provider_response_model")
explicit_pricing: Final = custom_pricing is True or base_model is not None
configured_model: Final = model or (
completion_response_model if isinstance(completion_response_model, str) else None
)
bedrock_model_info: Final = _bedrock_pricing_info(
configured_model, invoked_model_id, custom_llm_provider, explicit_pricing
)
bedrock_pricing_model: Final = bedrock_model_info["key"] if bedrock_model_info is not None else None
priced_from_response: Final = provider_response_model is not None or completion_response_model is not None
priced_region: Final = (
_get_hidden_str_for_cost_calc(hidden_params, "region_name") or region_name
@ -931,8 +999,8 @@ def select_model_name_for_cost_calc(
else:
return_model = model
elif base_model is not None or provider_response_model is not None:
return_model = base_model if base_model is not None else provider_response_model
elif base_model is not None or bedrock_pricing_model is not None or provider_response_model is not None:
return_model = base_model if base_model is not None else bedrock_pricing_model or provider_response_model
elif completion_response_model is None and hidden_params is not None:
if hidden_params.get("model", None) is not None and len(hidden_params["model"]) > 0:
@ -1103,6 +1171,8 @@ def get_usage_object(
if isinstance(completion_response, (DecisionsResponse, OpenAIDecisionResponse)):
return None if completion_response.usage is None else _decisions_usage(completion_response.usage)
if isinstance(completion_response, HttpxBinaryResponseContent):
return completion_response.usage
if usage_obj is None:
return None
if isinstance(usage_obj, Usage):
@ -1441,6 +1511,7 @@ def completion_cost(
litellm_model_name: str | None = None,
router_model_id: str | None = None,
litellm_logging_obj: LitellmLoggingObject | None = None,
pricing_provider: str | None = None,
### SERVICE TIER ###
service_tier: str | None = None, # for OpenAI service tier pricing
### DATA RESIDENCY ###
@ -1461,6 +1532,8 @@ def completion_cost(
"""
try:
call_type = infer_call_type(call_type, completion_response) or "completion"
if pricing_provider is not None:
custom_llm_provider = pricing_provider # rebind-ok: model_info.pricing_provider overrides the routing provider for cost-map lookups
if call_type == CallTypes.aresponses_websocket.value and isinstance(
completion_response, LiteLLMRealtimeStreamLoggingObject
@ -1486,6 +1559,7 @@ def completion_cost(
optional_params=optional_params,
custom_pricing=custom_pricing,
base_model=base_model,
pricing_provider=pricing_provider,
standard_built_in_tools_params=standard_built_in_tools_params,
litellm_model_name=litellm_model_name,
router_model_id=router_model_id,
@ -1544,6 +1618,10 @@ def completion_cost(
if base_model is not None and custom_pricing is not True
else None
)
invoked_model_id: Final = _invoked_model_id_for_cost(
_LOGGED_LITELLM_PARAMS.validate_python(optional_params) if optional_params is not None else None,
litellm_logging_obj,
)
selected_model: Final = select_model_name_for_cost_calc(
model=model,
completion_response=completion_response,
@ -1552,8 +1630,15 @@ def completion_cost(
base_model=base_model,
router_model_id=router_model_id,
region_name=region_name,
invoked_model_id=invoked_model_id,
)
bedrock_model_info: Final = _bedrock_pricing_info(
model or _get_response_model(completion_response),
invoked_model_id,
_get_provider_for_cost_calc(model, custom_llm_provider),
explicit_pricing,
)
requested_model: Final = model
request_pricing_provider: Final = custom_llm_provider if isinstance(custom_llm_provider, str) else None
raw_candidates: Final = (
@ -1565,10 +1650,10 @@ def completion_cost(
*(((model, request_pricing_provider),) if model is not None else ()),
)
potential_model_names: Final = (
raw_candidates if explicit_pricing else _priced_candidates_before_unpriced_rules(raw_candidates)
raw_candidates if custom_pricing is True else _priced_candidates_before_unpriced_rules(raw_candidates)
)
for idx, (model, pricing_provider) in enumerate(potential_model_names):
for idx, (model, candidate_pricing_provider) in enumerate(potential_model_names):
try:
if verbose_logger.isEnabledFor(logging.DEBUG):
verbose_logger.debug("selected model name for cost calculation: %s", model)
@ -1635,7 +1720,9 @@ def completion_cost(
hidden_params = getattr(completion_response, "_hidden_params", None)
if hidden_params is not None:
custom_llm_provider = hidden_params.get("custom_llm_provider", custom_llm_provider or None)
custom_llm_provider = pricing_provider or hidden_params.get(
"custom_llm_provider", custom_llm_provider or None
)
region_name = hidden_params.get("region_name", region_name)
else:
@ -1921,7 +2008,7 @@ def completion_cost(
prompt_tokens=prompt_tokens or 0,
completion_tokens=completion_tokens or 0,
custom_llm_provider=_pricing_provider_for_cost_candidate(
candidate_provider=pricing_provider,
candidate_provider=candidate_pricing_provider,
request_provider=request_pricing_provider,
current_provider=custom_llm_provider,
),
@ -1942,6 +2029,7 @@ def completion_cost(
vertex_location=vertex_location,
response=completion_response,
custom_model_info=_ocr_model_info(litellm_logging_obj, custom_pricing, router_model_id),
bedrock_model_info=bedrock_model_info,
)
# Get additional costs from provider (e.g., routing fees, infrastructure costs)
@ -2024,7 +2112,7 @@ def completion_cost(
_token_type_breakdown = get_token_type_cost_breakdown(
model=model,
custom_llm_provider=_pricing_provider_for_cost_candidate(
candidate_provider=pricing_provider,
candidate_provider=candidate_pricing_provider,
request_provider=request_pricing_provider,
current_provider=custom_llm_provider,
),
@ -2033,6 +2121,7 @@ def completion_cost(
data_residency=data_residency,
vertex_location=vertex_location,
custom_cost_per_token=custom_cost_per_token,
model_info=bedrock_model_info,
)
_reasoning_cost = _token_type_breakdown.reasoning_cost
_cache_read_cost = _token_type_breakdown.cache_read_cost
@ -2146,6 +2235,7 @@ def response_cost_calculator(
litellm_model_name: str | None = None,
router_model_id: str | None = None,
litellm_logging_obj: LitellmLoggingObject | None = None,
pricing_provider: str | None = None,
### SERVICE TIER ###
service_tier: str | None = None, # for OpenAI service tier pricing
### DATA RESIDENCY ###
@ -2186,6 +2276,7 @@ def response_cost_calculator(
litellm_model_name=litellm_model_name,
router_model_id=router_model_id,
litellm_logging_obj=litellm_logging_obj,
pricing_provider=pricing_provider,
service_tier=service_tier,
data_residency=data_residency,
vertex_location=vertex_location,
@ -2335,7 +2426,16 @@ def pricing_entry_for_cost_calc(
custom_llm_provider=custom_llm_provider,
router_model_id=router_model_id,
region_name=region_name,
invoked_model_id=_invoked_model_id_for_cost(None, litellm_logging_obj),
)
bedrock_model_info: Final = _bedrock_pricing_info(
model or _get_response_model(completion_response),
_invoked_model_id_for_cost(None, litellm_logging_obj),
_get_provider_for_cost_calc(model, custom_llm_provider),
custom_pricing is True or base_model is not None,
)
if bedrock_model_info is not None:
return bedrock_model_info["key"], bedrock_model_info
candidates: Final = (selected_model, _get_response_model(completion_response), model)
resolved: Final = next(
(info for info in (_cost_map_model_info(name, custom_llm_provider) for name in candidates if name) if info),
@ -2783,6 +2883,8 @@ def batch_cost_calculator(
custom_llm_provider: str | None = None,
model_info: ModelInfo | None = None,
data_residency: str | None = None,
*,
vertex_location: str | None = None,
) -> tuple[float, float]:
"""
Calculate the cost of a batch job.
@ -2872,7 +2974,8 @@ def batch_cost_calculator(
total_prompt_cost *= uplift
total_completion_cost *= uplift
return total_prompt_cost, total_completion_cost
vertex_uplift: Final = get_vertex_regional_endpoint_uplift(model_info, vertex_location)
return total_prompt_cost * vertex_uplift, total_completion_cost * vertex_uplift
def _batch_prompt_cost(

View file

@ -1,3 +1,4 @@
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field, replace
from functools import partial
@ -6,9 +7,11 @@ from typing import Final, Literal, TypeAlias
import httpx
from pydantic import BaseModel, ConfigDict, StrictBool, TypeAdapter, ValidationError
from pydantic_core import to_jsonable_python
from typing_extensions import assert_never
import litellm
from litellm.cost_calculator import cost_map_key_prices_anything
from litellm.litellm_core_utils.core_helpers import RESPONSE_COST_HEADER, normalize_drop_params
from litellm.litellm_core_utils.get_provider_specific_headers import ProviderSpecificHeaderUtils
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -19,6 +22,7 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import (
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.decisions.transformation import (
BaseDecisionsConfig,
DecisionsResponseValidationError,
image_media_type,
image_param,
input_images,
@ -56,6 +60,7 @@ from litellm.types.decisions import (
OpenAIDecisionResponse,
UnsupportedDecisionsRequest,
)
from litellm.types.llms.openai import ChatCompletionUserMessage
from litellm.types.utils import LlmProviders, ProviderSpecificHeader
from litellm.utils import ProviderConfigManager, get_model_cost_key
@ -415,6 +420,30 @@ def _outbound_headers(
return MappingProxyType({**(extra_headers or {}), **forwarded})
_LOGGED_FIELDS_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
def _jsonable_fallback(value: object) -> object:
return dict(_LOGGED_FIELDS_ADAPTER.validate_python(value)) if isinstance(value, Mapping) else str(value)
def _logged_request_message(caller_request: object) -> ChatCompletionUserMessage:
caller_fields: Final = _LOGGED_FIELDS_ADAPTER.validate_python(
to_jsonable_python(caller_request, fallback=_jsonable_fallback, bytes_mode="base64")
)
sent_fields: Final = json.dumps(
dict((name, value) for name, value in caller_fields.items() if value not in (None, [], {}))
)
return ChatCompletionUserMessage(role="user", content=sent_fields)
def log_caller_request(kwargs: Mapping[str, object], caller_request: object) -> None:
"""Record every request field the caller sent as the logged `messages`, before any validation."""
logging_obj: Final = kwargs.get("litellm_logging_obj")
if isinstance(logging_obj, LiteLLMLoggingObj):
logging_obj.update_messages([_logged_request_message(caller_request)])
def prepare_call(
*,
model: str,
@ -660,6 +689,13 @@ def _request_body(call: DecisionsCall) -> Mapping[str, object]:
return body
def _pricing_model(response: DecisionsIRResponse, call: DecisionsCall) -> str:
returned: Final = get_model_cost_key(f"{call.custom_llm_provider}/{response.model}")
if returned is not None and cost_map_key_prices_anything(returned):
return returned
return f"{call.custom_llm_provider}/{call.model}"
def _format_response(response: DecisionsIRResponse, call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse:
formatted: Final = _formatted_response(response, call)
provider_cost: Final = call.provider_config.provider_reported_cost(response)
@ -667,10 +703,7 @@ def _format_response(response: DecisionsIRResponse, call: DecisionsCall) -> Deci
{
"model": f"{call.custom_llm_provider}/{call.model}",
"custom_llm_provider": call.custom_llm_provider,
"provider_response_model": (
get_model_cost_key(f"{call.custom_llm_provider}/{response.model}")
or f"{call.custom_llm_provider}/{call.model}"
),
"provider_response_model": _pricing_model(response, call),
**({"additional_headers": {RESPONSE_COST_HEADER: provider_cost}} if provider_cost is not None else {}),
}
)
@ -700,18 +733,21 @@ def _formatted_response(
def _map_upstream_exception(error: Exception, call: DecisionsCall) -> Exception:
if isinstance(error, BaseLLMException) and error.status_code_is_synthesized:
provider_label: Final = f"{call.custom_llm_provider[0].upper()}{call.custom_llm_provider[1:]}Exception"
return litellm.APIConnectionError(
message=f"{provider_label} - {error.message}",
llm_provider=call.custom_llm_provider,
model=f"{call.custom_llm_provider}/{call.model}",
)
return litellm.exception_type(
model=f"{call.custom_llm_provider}/{call.model}",
custom_llm_provider=call.custom_llm_provider,
original_exception=error,
)
provider_label: Final = f"{call.custom_llm_provider[0].upper()}{call.custom_llm_provider[1:]}Exception"
model: Final = f"{call.custom_llm_provider}/{call.model}"
match error:
case DecisionsResponseValidationError():
return litellm.InternalServerError(
message=f"{provider_label} - {error.message}", llm_provider=call.custom_llm_provider, model=model
)
case BaseLLMException(status_code_is_synthesized=True):
return litellm.APIConnectionError(
message=f"{provider_label} - {error.message}", llm_provider=call.custom_llm_provider, model=model
)
case _:
return litellm.exception_type(
model=model, custom_llm_provider=call.custom_llm_provider, original_exception=error
)
async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse:

View file

@ -17,6 +17,7 @@ from litellm.decisions.call import (
SystemOneQuestions,
SystemOneRequestFields,
asend,
log_caller_request,
prepare_call,
reject_images_next_to_input,
send,
@ -78,6 +79,16 @@ def _prepare_decisions_call(
extra_headers: Mapping[str, str] | None,
kwargs: Mapping[str, object],
) -> DecisionsCall:
log_caller_request(
kwargs,
{
"input": decision_input,
"questions": questions,
"audio": audio,
"videos": videos,
**{key: kwargs[key] for key in _SYSTEMONE_FIELDS if key in kwargs},
},
)
return prepare_call(
model=model,
fields=_request_fields(model, decision_input, questions, audio, videos, kwargs),

View file

@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, Final
from typing_extensions import TypedDict
from litellm._internal_context import internal_sub_call
if TYPE_CHECKING:
from litellm import LiteLLMLoggingObj
from litellm.types.llms.openai import HttpxBinaryResponseContent
@ -111,9 +113,10 @@ class SpeechToCompletionBridgeHandler:
voice=voice,
)
result: Final = completion(
**request_data,
)
with internal_sub_call():
result: Final = completion(
**request_data,
)
requested_response_format: Final = optional_params.get("response_format")
if isinstance(result, ModelResponse):

View file

@ -5,11 +5,12 @@ from typing import TYPE_CHECKING, Final, cast
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.constants import OPENAI_CHAT_COMPLETION_PARAMS
from litellm.litellm_core_utils.hidden_params import get_hidden_params, served_from_cache, set_hidden_param
if TYPE_CHECKING:
from litellm import Logging as LiteLLMLoggingObj
from litellm.types.llms.openai import ChatCompletionUserMessage, HttpxBinaryResponseContent
from litellm.types.utils import ModelResponse
from litellm.types.utils import ModelResponse, Usage
def _completion_response_cost(model_response: "ModelResponse") -> float | None:
@ -20,6 +21,13 @@ def _completion_response_cost(model_response: "ModelResponse") -> float | None:
return response_cost if isinstance(response_cost, float) else None
def _completion_usage(model_response: "ModelResponse") -> "Usage | None":
from litellm.types.utils import Usage
usage: Final = getattr(model_response, "usage", None)
return usage if isinstance(usage, Usage) else None
GEMINI_TTS_CHAT_AUDIO_FORMAT: Final = "pcm16"
GEMINI_TTS_RAW_RESPONSE_FORMAT: Final = "pcm"
GEMINI_TTS_SUPPORTED_RESPONSE_FORMATS: Final = frozenset({"wav", GEMINI_TTS_RAW_RESPONSE_FORMAT})
@ -179,4 +187,9 @@ class SpeechToCompletionBridgeTransformationHandler:
)
binary_response: Final = HttpxBinaryResponseContent(response)
binary_response.set_response_cost(_completion_response_cost(model_response))
binary_response.usage = _completion_usage(model_response)
cached_hidden_params: Final = get_hidden_params(model_response) if served_from_cache(model_response) else None
if cached_hidden_params is not None:
set_hidden_param(binary_response, "cache_hit", True)
set_hidden_param(binary_response, "cache_key", cached_hidden_params.get("cache_key"))
return binary_response

View file

@ -173,6 +173,7 @@ def create_fine_tuning_job(
try:
_is_async: Final = kwargs.pop("acreate_fine_tuning_job", False) is True
optional_params: Final = GenericLiteLLMParams(**kwargs)
provider_model: Final = model.removeprefix(f"{custom_llm_provider}/")
# handle hyperparameters
hyperparameters = hyperparameters or {} # original hyperparameters
@ -218,7 +219,7 @@ def create_fine_tuning_job(
)
create_fine_tuning_job_data_dict = _build_fine_tuning_job_data(
model,
provider_model,
training_file,
_oai_hyperparameters,
suffix,
@ -263,7 +264,7 @@ def create_fine_tuning_job(
extra_body = _prepare_azure_extra_body(extra_body, kwargs, azure_specific_hyperparams)
create_fine_tuning_job_data_dict = _build_fine_tuning_job_data(
model,
provider_model,
training_file,
_oai_hyperparameters,
suffix,
@ -298,7 +299,7 @@ def create_fine_tuning_job(
response = vertex_fine_tuning_apis_instance.create_fine_tuning_job(
_is_async=_is_async,
create_fine_tuning_job_data=_build_fine_tuning_job_data(
model,
provider_model,
training_file,
_oai_hyperparameters,
suffix,

View file

@ -71,7 +71,7 @@ class BaseGoogleGenAIGenerateContentStreamingIterator:
):
self.litellm_logging_obj = litellm_logging_obj
self.request_body = request_body
self.start_time = datetime.now()
self.start_time = litellm_logging_obj.start_time
self.collected_chunks: list[bytes] = []
self.model = model
self.custom_llm_provider = custom_llm_provider
@ -192,6 +192,8 @@ class AsyncGoogleGenAIGenerateContentStreamingIterator(BaseGoogleGenAIGenerateCo
async def __anext__(self):
try:
chunk: Final = await _anext_google_genai_sse_chunk(self.stream_iterator)
if self.litellm_logging_obj.completion_start_time is None:
self.litellm_logging_obj.update_completion_start_time(completion_start_time=datetime.now())
self.collected_chunks.append(chunk)
return chunk
except StopAsyncIteration:

View file

@ -1,4 +1,4 @@
"""Agent harnesses: run Claude Code, Codex, OpenCode, Deep Agents or Tool Loop on any LiteLLM model.
"""Agent harnesses: run Claude Code, Codex, OpenCode, pi, Deep Agents or Tool Loop on any LiteLLM model.
The entrypoints live on the top-level package:
@ -30,6 +30,7 @@ from litellm.harness.options import (
CodexOptions,
DeepAgentsOptions,
OpenCodeOptions,
PiOptions,
ToolLoopOptions,
)
from litellm.harness.runtime import (
@ -78,6 +79,7 @@ __all__ = (
"OpenCodeOptions",
"OptionsMismatch",
"OutputInvalid",
"PiOptions",
"Reasoning",
"Result",
"SandboxError",

View file

@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any, Final, Protocol
import httpx
import openai
from pydantic import ConfigDict, TypeAdapter
from pydantic import ConfigDict, TypeAdapter, ValidationError
import litellm
from litellm.constants import (
@ -172,16 +172,57 @@ def usage_from_mapping(usage: object) -> tuple[int, int]:
return _as_int(input_tokens), _as_int(output_tokens)
def usage_from_body(body: object) -> tuple[int, int]:
"""Usage from a non-streaming JSON response body."""
if not isinstance(body, Mapping):
return 0, 0
if isinstance(body.get("usage"), Mapping):
return usage_from_mapping(body["usage"])
response = body.get("response")
if isinstance(response, Mapping):
return usage_from_mapping(response.get("usage"))
return 0, 0
@dataclass(frozen=True, slots=True)
class PromptCache:
"""Prompt tokens read from or written to the provider's cache. Anthropic's usage counts them outside input_tokens,
while OpenAI's prompt_tokens and the Responses API's input_tokens already include them."""
read_tokens: int = 0
creation_tokens: int = 0
outside_input: bool = False
def prompt_tokens(self, input_tokens: int) -> int:
if self.outside_input:
return input_tokens + self.read_tokens + self.creation_tokens
return input_tokens
NO_PROMPT_CACHE: Final = PromptCache()
def json_object(value: object) -> Mapping[str, object]:
try:
return _JSON_OBJECT_ADAPTER.validate_python(value)
except ValidationError:
return MappingProxyType({})
def prompt_cache_from_mapping(usage: object) -> PromptCache:
"""Cache token counts from a usage dict using OpenAI, Anthropic, or Responses field names."""
fields: Final = json_object(usage)
details: Final = json_object(fields.get("prompt_tokens_details", fields.get("input_tokens_details")))
return PromptCache(
read_tokens=_as_int(fields.get("cache_read_input_tokens") or details.get("cached_tokens")),
creation_tokens=_as_int(
fields.get("cache_creation_input_tokens")
or details.get("cache_write_tokens")
or details.get("cache_creation_tokens")
),
outside_input="prompt_tokens" not in fields and "input_tokens_details" not in fields,
)
def usage_mapping_of_body(body: object) -> Mapping[str, object]:
fields: Final = json_object(body)
usage: Final = json_object(fields.get("usage"))
return usage if usage else json_object(json_object(fields.get("response")).get("usage"))
def usage_mapping_of_json(raw: bytes) -> Mapping[str, object]:
try:
return usage_mapping_of_body(_JSON_VALUE_ADAPTER.validate_python(json.loads(raw)))
except ValueError:
return MappingProxyType({})
class SSEUsageParser:
@ -190,6 +231,7 @@ class SSEUsageParser:
def __init__(self) -> None:
self.input_tokens = 0
self.output_tokens = 0
self.prompt_cache = NO_PROMPT_CACHE
self._buffer = b""
def feed(self, chunk: bytes) -> None:
@ -226,21 +268,23 @@ class SSEUsageParser:
elif event_type == "response.completed":
self._absorb_response_completed(event)
elif isinstance(event.get("usage"), Mapping):
self._set(*usage_from_mapping(event["usage"]))
self._absorb_usage(event["usage"])
def _absorb_message_start(self, event: Mapping[str, object]) -> None:
message = event.get("message")
if isinstance(message, Mapping):
self._set(*usage_from_mapping(message.get("usage")))
self._absorb_usage(json_object(event.get("message")).get("usage"))
def _absorb_message_delta(self, event: Mapping[str, object]) -> None:
# message_delta output_tokens is cumulative for the whole message.
self._set(*usage_from_mapping(event.get("usage")))
self._absorb_usage(event.get("usage"))
def _absorb_response_completed(self, event: Mapping[str, object]) -> None:
response = event.get("response")
if isinstance(response, Mapping):
self._set(*usage_from_mapping(response.get("usage")))
self._absorb_usage(json_object(event.get("response")).get("usage"))
def _absorb_usage(self, usage: object) -> None:
self._set(*usage_from_mapping(usage))
prompt_cache: Final = prompt_cache_from_mapping(usage)
if prompt_cache.read_tokens or prompt_cache.creation_tokens:
self.prompt_cache = prompt_cache
def _set(self, input_tokens: int, output_tokens: int) -> None:
if input_tokens:
@ -249,13 +293,19 @@ class SSEUsageParser:
self.output_tokens = output_tokens
def compute_cost(model: str | None, input_tokens: int, output_tokens: int) -> float:
"""Cost from LiteLLM's price map. Never raises; unknown models cost 0.0."""
def compute_cost(
model: str | None, input_tokens: int, output_tokens: int, prompt_cache: PromptCache = NO_PROMPT_CACHE
) -> float:
"""Cost from LiteLLM's price map, cached prompt tokens at their cache prices. Never raises; unknown models cost 0.0."""
if not model or not (input_tokens or output_tokens):
return 0.0
try:
prompt_cost, completion_cost = litellm.cost_per_token(
model=model, prompt_tokens=input_tokens, completion_tokens=output_tokens
model=model,
prompt_tokens=prompt_cache.prompt_tokens(input_tokens),
completion_tokens=output_tokens,
cache_read_input_tokens=prompt_cache.read_tokens,
cache_creation_input_tokens=prompt_cache.creation_tokens,
)
return float(prompt_cost) + float(completion_cost)
except Exception: # accounting must never break a call; the price-map lookup raises bare Exception
@ -565,9 +615,10 @@ class ModelEndpoint:
input_tokens: int,
output_tokens: int,
cost: float | None,
prompt_cache: PromptCache,
) -> None:
if cost is None:
cost = compute_cost(model, input_tokens, output_tokens)
cost = compute_cost(model, input_tokens, output_tokens, prompt_cache)
self.usage.add(input_tokens, output_tokens, cost)
async def _forward(self, request: Request, route: str, body: Mapping[str, object]) -> Response:
@ -617,14 +668,12 @@ class ModelEndpoint:
) -> None:
if is_sse:
parser.close()
tokens = (parser.input_tokens, parser.output_tokens)
else:
try:
body: Final[object] = _JSON_VALUE_ADAPTER.validate_python(json.loads(collected))
tokens = usage_from_body(body)
except ValueError:
tokens = (0, 0)
self._record(model, tokens[0], tokens[1], header_cost(upstream.headers))
self._record(
model, parser.input_tokens, parser.output_tokens, header_cost(upstream.headers), parser.prompt_cache
)
return
usage: Final = usage_mapping_of_json(collected)
self._record(model, *usage_from_mapping(usage), header_cost(upstream.headers), prompt_cache_from_mapping(usage))
def _sdk_kwargs(
self, body: Mapping[str, object]
@ -667,9 +716,9 @@ class ModelEndpoint:
return self._responses.StreamingResponse(
self._sdk_stream(route, response, model), media_type=SSE_MEDIA_TYPE
)
data = to_jsonable(response)
input_tokens, output_tokens = usage_from_body(data)
self._record(model, input_tokens, output_tokens, hidden_cost(response))
data: Final = to_jsonable(response)
usage: Final = usage_mapping_of_body(data)
self._record(model, *usage_from_mapping(usage), hidden_cost(response), prompt_cache_from_mapping(usage))
return self._json(data)
async def _sdk_stream(self, route: str, iterator: AsyncIterable[object], model: str | None) -> AsyncIterator[bytes]:
@ -688,4 +737,4 @@ class ModelEndpoint:
yield f"event: error\ndata: {json.dumps(error_body(e, message))}\n\n".encode()
finally:
parser.close()
self._record(model, parser.input_tokens, parser.output_tokens, None)
self._record(model, parser.input_tokens, parser.output_tokens, None, parser.prompt_cache)

View file

@ -12,17 +12,27 @@ from __future__ import annotations
import asyncio
import os
from collections import deque
from collections.abc import AsyncIterator, Sequence
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass
from typing import Final
from litellm._logging import verbose_logger
from litellm.constants import HARNESS_STDERR_TAIL_LINES, HARNESS_STREAM_READ_CHUNK_BYTES
from litellm.constants import (
HARNESS_RESUME_COMMAND_TIMEOUT_SECONDS,
HARNESS_STDERR_TAIL_LINES,
HARNESS_STREAM_READ_CHUNK_BYTES,
)
from litellm.harness.context import SessionContext
from litellm.harness.errors import HarnessInstallFailed, SandboxError
from litellm.harness.errors import HarnessError, HarnessInstallFailed, SandboxError
from litellm.harness.handlers.base import BaseHarnessHandler
from litellm.harness.sandbox.base import Process, Sandbox
from litellm.harness.sandbox.base import CompletedRun, Process, Sandbox
from litellm.harness.types import Event
from litellm.llms.base_llm.harness.transformation import BaseCLIHarnessConfig, HarnessSessionSetup
from litellm.llms.base_llm.harness.transformation import (
BaseCLIHarnessConfig,
HarnessSessionCopy,
HarnessSessionSetup,
HarnessTurnError,
)
from litellm.llms.base_llm.harness.utils import decode_json_line, read_skill_files
# Link <private_dir>/<dir> to a LiteLLM-owned cache dir so a later session can resume.
@ -81,6 +91,52 @@ async def persist_dir(sandbox: Sandbox, link_path: str, cache_subpath: str) -> N
)
@dataclass(frozen=True, slots=True)
class ResumeFailed:
reason: str
async def bounded_resume_run(
sandbox: Sandbox, cmd: Sequence[str], env: Mapping[str, str]
) -> CompletedRun | ResumeFailed:
try:
run: Final = await sandbox.run(cmd, env=env, timeout=HARNESS_RESUME_COMMAND_TIMEOUT_SECONDS)
except SandboxError as exc:
return ResumeFailed(str(exc))
return run if run.exit_code == 0 else ResumeFailed(run.stderr.strip() or f"exit code {run.exit_code}")
async def import_session_copy(sandbox: Sandbox, copy: HarnessSessionCopy, env: Mapping[str, str]) -> str | ResumeFailed:
try:
await sandbox.write(copy.path, copy.data)
except SandboxError as exc:
return ResumeFailed(str(exc))
imported: Final = await bounded_resume_run(sandbox, copy.import_command, env)
return imported if isinstance(imported, ResumeFailed) else copy.session_id
async def resumable_session_id(
config: BaseCLIHarnessConfig[object, object],
sandbox: Sandbox,
setup_env: Mapping[str, str],
private_dir: str,
native_session_id: str,
) -> str | ResumeFailed:
export_cmd: Final = config.transform_resume_export_command(native_session_id)
if export_cmd is None:
return native_session_id
exported: Final = await bounded_resume_run(sandbox, export_cmd, setup_env)
if isinstance(exported, ResumeFailed):
return exported
try:
copy: Final = config.transform_resume_copy(exported.stdout, private_dir, sandbox.workdir)
except HarnessError as exc:
return ResumeFailed(str(exc))
if copy is None:
return native_session_id
return await import_session_copy(sandbox, copy, setup_env)
async def copy_skills(sandbox: Sandbox, skills: Sequence[str], skills_root: str) -> None:
for skill in skills:
name = os.path.basename(os.path.realpath(os.fspath(skill)))
@ -89,13 +145,14 @@ async def copy_skills(sandbox: Sandbox, skills: Sequence[str], skills_root: str)
class CLIHarnessHandler(BaseHarnessHandler):
config: BaseCLIHarnessConfig
config: BaseCLIHarnessConfig[object, object]
def __init__(self, config: BaseCLIHarnessConfig) -> None:
super().__init__(config)
self._private_dir: str | None = None
self._setup: HarnessSessionSetup | None = None
self._native_id: str | None = None
self._pending_resume: str | None = None
self._proc: Process | None = None
async def start(self, ctx: SessionContext) -> None:
@ -119,6 +176,9 @@ class CLIHarnessHandler(BaseHarnessHandler):
async def turn(self, ctx: SessionContext, prompt: str) -> AsyncIterator[Event]:
if self._setup is None or self._private_dir is None:
raise RuntimeError("CLIHarnessHandler.turn() called before start()")
if self._pending_resume is not None:
self._native_id = await self._prepared_session_id(ctx, self._setup, self._private_dir, self._pending_resume)
self._pending_resume = None
request = self.config.transform_turn_request(ctx, self._setup, self._private_dir, prompt, self._native_id)
argv: Final = list(request.argv) # mutable-ok: Sandbox.exec takes list[str]
proc = await ctx.sandbox.exec(argv, env=request.env, cwd=request.cwd)
@ -158,4 +218,16 @@ class CLIHarnessHandler(BaseHarnessHandler):
return self._native_id
async def resume(self, ctx: SessionContext, native_session_id: str) -> None:
self._native_id = native_session_id
if native_session_id != self._native_id:
self._native_id = native_session_id
self._pending_resume = native_session_id
async def _prepared_session_id(
self, ctx: SessionContext, setup: HarnessSessionSetup, private_dir: str, saved_id: str
) -> str:
target: Final = await resumable_session_id(self.config, ctx.sandbox, setup.env, private_dir, saved_id)
match target:
case ResumeFailed(reason=reason):
raise HarnessTurnError(f"could not prepare session {saved_id} for resume: {reason}")
case session_id:
return session_id

View file

@ -183,7 +183,7 @@ class DeepAgentsHandler(BaseHarnessHandler):
break
resume: dict[str, object] = {} # mutable-ok: Command(resume=) payload, filled per answered approval
for interrupt in state.interrupts:
decisions: list[dict[str, Any]] = [] # mutable-ok: HITL decisions collected across awaited approvals
decisions: list[dict[str, str]] = [] # mutable-ok: HITL decisions collected across awaited approvals
for request in approval_requests(getattr(interrupt, "value", None)):
approval = Approval(
tool=normalized_tool_name(str(request.get("name") or "")),

View file

@ -4,28 +4,37 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal
from typing import Literal, TypeAlias
PiThinkingLevel: TypeAlias = Literal["off", "minimal", "low", "medium", "high", "xhigh", "max"]
@dataclass(frozen=True)
class ClaudeCodeOptions:
config: Mapping[str, object] = field(default_factory=dict)
env: Mapping[str, str] = field(default_factory=dict)
config: Mapping[str, object] = field(default_factory=dict[str, object])
env: Mapping[str, str] = field(default_factory=dict[str, str])
@dataclass(frozen=True)
class CodexOptions:
reasoning_effort: Literal["low", "medium", "high", "xhigh"] | None = None
web_search: bool = False
config: Mapping[str, object] = field(default_factory=dict)
env: Mapping[str, str] = field(default_factory=dict)
config: Mapping[str, object] = field(default_factory=dict[str, object])
env: Mapping[str, str] = field(default_factory=dict[str, str])
@dataclass(frozen=True)
class OpenCodeOptions:
agent: str = "build"
config: Mapping[str, object] = field(default_factory=dict)
env: Mapping[str, str] = field(default_factory=dict)
config: Mapping[str, object] = field(default_factory=dict[str, object])
env: Mapping[str, str] = field(default_factory=dict[str, str])
@dataclass(frozen=True)
class PiOptions:
thinking: PiThinkingLevel | None = None
config: Mapping[str, object] = field(default_factory=dict[str, object])
env: Mapping[str, str] = field(default_factory=dict[str, str])
@dataclass(frozen=True)
@ -36,7 +45,9 @@ class DeepAgentsOptions:
@dataclass(frozen=True)
class ToolLoopOptions:
completion_kwargs: Mapping[str, object] = field(default_factory=dict)
completion_kwargs: Mapping[str, object] = field(default_factory=dict[str, object])
HarnessOptions = ClaudeCodeOptions | CodexOptions | OpenCodeOptions | DeepAgentsOptions | ToolLoopOptions
HarnessOptions: TypeAlias = (
ClaudeCodeOptions | CodexOptions | OpenCodeOptions | PiOptions | DeepAgentsOptions | ToolLoopOptions
)

View file

@ -14,7 +14,7 @@ from typing import Final
from litellm.constants import HARNESS_PROCESS_KILL_GRACE_SECONDS
from litellm.harness.errors import SandboxError
from litellm.harness.sandbox.base import CompletedRun
from litellm.harness.sandbox.base import CompletedRun, Process
from litellm.harness.sandbox.snapshot import snapshot_local
_SECRET_PREFIXES: Final = (
@ -103,7 +103,7 @@ class SubprocessHandle:
await self._proc.wait()
async def _read_all(handle: SubprocessHandle) -> tuple[bytes, bytes, int]:
async def _read_all(handle: Process) -> tuple[bytes, bytes, int]:
if handle.stdin is not None:
handle.stdin.close()
stdout, stderr = await asyncio.gather(handle.stdout.read(), handle.stderr.read())
@ -111,13 +111,16 @@ async def _read_all(handle: SubprocessHandle) -> tuple[bytes, bytes, int]:
return stdout, stderr, exit_code
async def collect_output(handle: SubprocessHandle, cmd: Sequence[str], timeout: float | None) -> CompletedRun:
"""Close stdin, read stdout/stderr to EOF; kill and raise SandboxError on timeout."""
async def collect_output(handle: Process, cmd: Sequence[str], timeout: float | None) -> CompletedRun:
"""Close stdin, read stdout/stderr to EOF; kill on cancel, and kill and raise SandboxError on timeout."""
try:
stdout, stderr, code = await asyncio.wait_for(_read_all(handle), timeout)
except asyncio.TimeoutError:
await handle.kill()
raise SandboxError(f"command timed out after {timeout}s: {cmd[0]}")
except asyncio.CancelledError:
await handle.kill()
raise
return CompletedRun(
stdout=stdout.decode("utf-8", errors="replace"),
stderr=stderr.decode("utf-8", errors="replace"),
@ -180,14 +183,15 @@ class LocalSandbox:
self._check_open()
if not cmd:
raise SandboxError("exec() needs a non-empty command")
work_dir: Final = self._resolve_cwd(cwd)
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self._resolve_cwd(cwd),
env=self.child_env(env),
cwd=work_dir,
env=self.child_env(MappingProxyType({**(env or {}), "PWD": work_dir})),
start_new_session=True,
)
except (FileNotFoundError, PermissionError) as exc:

View file

@ -411,7 +411,7 @@ def agent(
options: HarnessOptions | None = None,
install: bool = False,
) -> Result | EventStream:
"""Run an agent harness (Claude Code, Codex, OpenCode, Deep Agents, Tool Loop) on one prompt.
"""Run an agent harness (Claude Code, Codex, OpenCode, pi, Deep Agents, Tool Loop) on one prompt.
Returns a Result. With stream=True it returns an iterator of events instead.
Prefix the model with `litellm_proxy/` to route every model call through your

View file

@ -26,6 +26,7 @@ class Harness(Enum):
CLAUDE_CODE = "claude_code"
CODEX = "codex"
OPENCODE = "opencode"
PI = "pi"
DEEPAGENTS = "deepagents"
TOOL_LOOP = "tool_loop"

View file

@ -342,6 +342,20 @@ EXTERNAL_BREAKPOINTS_STAMP: Final = "_litellm_external_breakpoints"
CUSTOM_LLM_PROVIDER_STAMP: Final = "_litellm_custom_llm_provider"
def _accepts_cache_control(part: object) -> bool:
match part:
case {"type": "thinking" | "redacted_thinking"}:
return False
case dict():
return True
case _:
return False
def _last_index_accepting_cache_control(parts: Sequence[object]) -> int | None:
return next((index for index in reversed(range(len(parts))) if _accepts_cache_control(parts[index])), None)
class AnthropicCacheControlHook(CustomPromptManagement):
@staticmethod
def _request_value(request_kwargs: object, key: str) -> object:
@ -740,10 +754,11 @@ class AnthropicCacheControlHook(CustomPromptManagement):
# 1. if string, insert cache control in the message
if isinstance(message_content, str):
message["cache_control"] = control
# 2. list of objects - only apply to last item per Anthropic spec
# 2. list of objects - only apply to the last item Claude accepts a breakpoint on
elif isinstance(message_content, list):
if len(message_content) > 0 and isinstance(message_content[-1], dict):
message_content[-1]["cache_control"] = control # pyright: ignore[reportGeneralTypeIssues] # loose runtime dict
markable_index: Final = _last_index_accepting_cache_control(message_content)
if markable_index is not None:
message_content[markable_index]["cache_control"] = control # pyright: ignore[reportGeneralTypeIssues] # loose runtime dict
return message
@staticmethod

View file

@ -884,7 +884,7 @@ def _set_session_and_user_attrs(span: "Span", kwargs: dict, standard_logging_pay
safe_set_attribute(span, "litellm.key_alias", str(key_alias))
def _set_response_cost_attr(span: "Span", standard_logging_payload) -> None:
def _set_response_cost_attr(span: "Span", standard_logging_payload: Mapping[str, object] | None) -> None:
"""Emit cost attributes from the StandardLoggingPayload when present.
Uses the OpenInference `llm.cost.total` key so Arize / Phoenix can
@ -892,15 +892,22 @@ def _set_response_cost_attr(span: "Span", standard_logging_payload) -> None:
single total in `StandardLoggingPayload.response_cost`, so we cannot
split it into prompt/completion. We also keep the legacy
`llm.response.cost` key for back-compat with any consumer querying it.
When the price lookup failed, the call's total is unknown, so no cost is
emitted and the backend does not record the call as free. A guardrail fee
in `response_cost` stays on the guardrail span, as in OTel v2.
"""
if not isinstance(standard_logging_payload, dict):
return
cost: Final = standard_logging_payload.get("response_cost")
if cost is None:
cost_lookup_failed: Final = (
standard_logging_payload.get("response_cost_failure_debug_info") is not None
and standard_logging_payload.get("cache_hit") is not True
)
if not isinstance(cost, (int, float, str)) or cost_lookup_failed:
return
try:
cost_value: Final = float(cost)
except (TypeError, ValueError):
except ValueError:
return
safe_set_attribute(span, "llm.cost.total", cost_value)
safe_set_attribute(span, "llm.response.cost", cost_value)

View file

@ -354,17 +354,16 @@ class ArizePhoenixLogger(OpenTelemetry):
_project_name, tracer = self._resolve_tracer_for_kwargs(kwargs)
ctx, parent_span = self._get_phoenix_context(kwargs, tracer=tracer)
status: Final = Status(StatusCode.OK if success else StatusCode.ERROR)
status_code: Final = StatusCode.OK if success else StatusCode.ERROR
span: Final = tracer.start_span(
name=self._get_span_name(kwargs),
start_time=self._to_ns(start_time),
context=ctx,
)
span.set_status(status)
self.set_attributes(span, kwargs, response_obj)
if not success:
self._record_exception_on_span(span=span, kwargs=kwargs)
span_error_message: Final = None if success else self._record_exception_on_span(span=span, kwargs=kwargs)
span.set_status(Status(status_code, span_error_message))
if success:
self._maybe_log_raw_request(kwargs, response_obj, start_time, end_time, span)
@ -373,10 +372,11 @@ class ArizePhoenixLogger(OpenTelemetry):
self._create_guardrail_span(kwargs=kwargs, context=ctx)
if parent_span is not None:
parent_span.set_status(status)
self.set_attributes(parent_span, kwargs, response_obj)
if not success:
self._record_exception_on_span(span=parent_span, kwargs=kwargs)
parent_error_message: Final = (
None if success else self._record_exception_on_span(span=parent_span, kwargs=kwargs)
)
parent_span.set_status(Status(status_code, parent_error_message))
parent_span.end(end_time=self._to_ns(end_time))
if success:

View file

@ -12,7 +12,7 @@ import os
from collections.abc import Mapping, Sequence
from datetime import datetime
from types import MappingProxyType
from typing import Any, Final, Literal
from typing import Final, Literal
import httpx
from pydantic import ConfigDict, TypeAdapter, ValidationError
@ -54,6 +54,7 @@ from litellm.types.utils import (
PROMPT_CARRYING_GUARDRAIL_FIELDS,
PROMPT_QUOTING_ROUTING_DECISION_FIELDS,
CallTypes,
StandardLoggingMetadata,
StandardLoggingPayload,
StandardLoggingPayloadErrorInformation,
)
@ -93,8 +94,12 @@ _ROUTER_DIMENSIONS: Final[tuple[str, ...]] = ("router_tier", "router_cause", "ro
_COST_DIMENSIONS: Final[tuple[str, ...]] = ("team", "user", "key_alias", "model_group", *_ROUTER_DIMENSIONS)
def _metadata_of(standard_logging_payload: StandardLoggingPayload) -> Mapping[str, Any]:
metadata: Final = standard_logging_payload.get("metadata")
def _logging_metadata_of(standard_logging_payload: StandardLoggingPayload) -> StandardLoggingMetadata | None:
return standard_logging_payload.get("metadata")
def _metadata_of(standard_logging_payload: StandardLoggingPayload) -> Mapping[str, object]:
metadata: Final = _logging_metadata_of(standard_logging_payload)
return metadata or _EMPTY_MAPPING
@ -1057,14 +1062,23 @@ class DataDogLLMObsLogger(CustomBatchLogger):
# Get budget information from metadata
metadata: Final = _metadata_of(standard_logging_payload)
logging_metadata: Final = _logging_metadata_of(standard_logging_payload)
# API key max budget
user_api_key_max_budget: Final = metadata.get("user_api_key_max_budget")
user_api_key_max_budget: Final = (
logging_metadata["user_api_key_max_budget"]
if logging_metadata and "user_api_key_max_budget" in logging_metadata
else None
)
if user_api_key_max_budget is not None:
spend_metrics["user_api_key_max_budget"] = float(user_api_key_max_budget)
# API key spend
user_api_key_spend: Final = metadata.get("user_api_key_spend")
user_api_key_spend: Final = (
logging_metadata["user_api_key_spend"]
if logging_metadata and "user_api_key_spend" in logging_metadata
else None
)
if user_api_key_spend is not None:
try:
spend_metrics["user_api_key_spend"] = float(user_api_key_spend)

View file

@ -1,7 +1,7 @@
import base64
import json
import os
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, Final, Optional
@ -9,6 +9,7 @@ from litellm._logging import verbose_logger
from litellm.integrations.arize import _utils
from litellm.integrations.langfuse.langfuse_otel_attributes import (
LangfuseLLMObsOTELAttributes,
serialize_langfuse_datetime,
)
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
@ -55,7 +56,7 @@ class LangfuseOtelLogger(OpenTelemetry):
LangfuseOtelLogger._set_langfuse_specific_attributes(span=span, kwargs=kwargs, response_obj=response_obj)
@staticmethod
def _extract_langfuse_metadata(kwargs: dict) -> dict:
def _extract_langfuse_metadata(kwargs: Mapping[str, Any]) -> dict:
"""
Extracts Langfuse metadata from the standard LiteLLM kwargs structure.
@ -221,7 +222,7 @@ class LangfuseOtelLogger(OpenTelemetry):
)
@staticmethod
def _set_langfuse_specific_attributes(span: Span, kwargs, response_obj):
def _set_langfuse_specific_attributes(span: Span, kwargs: Mapping[str, Any], response_obj):
"""
Sets Langfuse specific metadata attributes onto the OTEL span.
@ -255,6 +256,14 @@ class LangfuseOtelLogger(OpenTelemetry):
safe_dumps(messages),
)
completion_start_time: Final = kwargs.get("completion_start_time")
if isinstance(completion_start_time, datetime):
safe_set_attribute(
span,
LangfuseSpanAttributes.OBSERVATION_COMPLETION_START_TIME.value,
serialize_langfuse_datetime(completion_start_time),
)
LangfuseOtelLogger._set_observation_output(span=span, response_obj=response_obj)
@staticmethod

View file

@ -6,6 +6,7 @@ Relevant Issue: https://github.com/BerriAI/litellm/issues/13764
import json
from collections.abc import Mapping
from datetime import datetime
from typing import TYPE_CHECKING, Any, Final
from pydantic import BaseModel
@ -29,6 +30,10 @@ if TYPE_CHECKING:
from opentelemetry.trace import Span
def serialize_langfuse_datetime(value: datetime) -> str:
return json.dumps(value.astimezone().isoformat())
def get_output_content_by_type(
response_obj: None
| dict

View file

@ -39,6 +39,7 @@ from pydantic import ConfigDict
import litellm
from litellm._logging import verbose_logger
from litellm.integrations.langfuse.langfuse import PROMPT_CACHE_TTL_ENV, parse_langfuse_debug, whole_number
from litellm.integrations.langfuse.langfuse_otel_attributes import serialize_langfuse_datetime
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_httpx_client
from litellm.types.llms.base import LiteLLMBaseModel
@ -168,7 +169,7 @@ def _string_or_none(value: object) -> str | None:
def _serialize_datetime(value: object) -> str | None:
"""A datetime the way the SDK's ``EventSerializer`` sends one: a JSON string, naive values read as local time."""
if isinstance(value, datetime):
return safe_dumps(value.astimezone().isoformat())
return serialize_langfuse_datetime(value)
return _serialize(value)

View file

@ -53,6 +53,7 @@ from litellm.types.utils import (
StandardCallbackDynamicParams,
StandardLoggingMetadata,
StandardLoggingPayload,
StandardLoggingPayloadErrorInformation,
)
# OpenTelemetry imports moved to individual functions to avoid import errors when not installed
@ -104,8 +105,26 @@ class _ResponseWithUsageView(TypedDict, total=False):
usage: "_UsageCompletionTokensView | None"
class _FailurePayloadView(TypedDict, total=False):
error_information: ReadOnly[StandardLoggingPayloadErrorInformation | None]
error_str: ReadOnly[str | None]
class _FailureKwargsView(TypedDict, total=False):
exception: ReadOnly[BaseException | None]
standard_logging_object: ReadOnly[_FailurePayloadView | None]
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True))
def _text_as_stored_on_span(span: Span, key: str, text: str) -> str:
from opentelemetry.sdk.trace import ReadableSpan
stored: Final = span.attributes.get(key) if isinstance(span, ReadableSpan) and span.attributes else None
return stored if isinstance(stored, str) else text
# Cap on credential-scoped providers held at once; each one owns an exporter thread.
_MAX_DYNAMIC_TRACER_PROVIDERS: Final = 256
@ -922,7 +941,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
value=value,
)
service_logging_span.set_status(Status(StatusCode.ERROR))
service_logging_span.set_status(
Status(
StatusCode.ERROR, _text_as_stored_on_span(service_logging_span, "error", error) if error else None
)
)
service_logging_span.end(end_time=_end_time_ns)
async def async_post_call_failure_hook(
@ -937,8 +960,6 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
parent_otel_span: Final = user_api_key_dict.parent_otel_span
if parent_otel_span is not None:
parent_otel_span.set_status(Status(StatusCode.ERROR))
# Stamp team attributes onto the SERVER (root) span too, so the
# trace root is team-filterable on the failure path like the
# child exception span below.
@ -960,13 +981,14 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
original_exception=original_exception,
traceback_str=traceback_str,
)
self._record_exception_on_span(
server_error_message: Final = self._record_exception_on_span(
span=parent_otel_span,
kwargs={
"exception": original_exception,
"standard_logging_object": {"error_information": error_information},
},
)
parent_otel_span.set_status(Status(StatusCode.ERROR, server_error_message))
# _record_exception_on_span only stamps when error_code is set;
# bare TypeError etc. has none, and the span is about to be ended.
@ -995,7 +1017,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
team_id=user_api_key_dict.team_id,
team_alias=user_api_key_dict.team_alias,
)
exception_logging_span.set_status(Status(StatusCode.ERROR))
exception_logging_span.set_status(Status(StatusCode.ERROR, server_error_message))
exception_logging_span.end(end_time=self._to_ns(datetime.now()))
# Emit guardrail spans for any guardrail invocations that
@ -2199,11 +2221,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
if self._gen_ai_semconv_latest_experimental:
span_kwargs["kind"] = self.span_kind.CLIENT
span = otel_tracer.start_span(**span_kwargs)
span.set_status(Status(StatusCode.ERROR))
self.set_attributes(span, kwargs, response_obj)
# Record exception information using OTEL standard method
self._record_exception_on_span(span=span, kwargs=kwargs)
span_error_message: Final = self._record_exception_on_span(span=span, kwargs=kwargs)
span.set_status(Status(StatusCode.ERROR, span_error_message))
span.end(end_time=self._to_ns(end_time))
else:
@ -2212,9 +2234,9 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
# Only set attributes if the span is still recording (not closed)
# Note: parent_otel_span is guaranteed to be not None here
if parent_otel_span.is_recording():
parent_otel_span.set_status(Status(StatusCode.ERROR))
self.set_attributes(parent_otel_span, kwargs, response_obj)
self._record_exception_on_span(span=parent_otel_span, kwargs=kwargs)
parent_error_message: Final = self._record_exception_on_span(span=parent_otel_span, kwargs=kwargs)
parent_otel_span.set_status(Status(StatusCode.ERROR, parent_error_message))
# Create span for guardrail information — ensure proper parenting (Issue #5)
guardrail_ctx: Final = self._resolve_guardrail_context(
@ -2232,13 +2254,15 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
):
parent_otel_span.end(end_time=self._to_ns(end_time))
def _record_exception_on_span(self, span: Span, kwargs: dict):
def _record_exception_on_span(self, span: Span, kwargs: _FailureKwargsView) -> str | None:
"""
Record exception information on the span using OTEL standard methods.
This extracts error information from StandardLoggingPayload and:
1. Uses span.record_exception() for the actual exception object (OTEL standard)
2. Sets structured error attributes from StandardLoggingPayloadErrorInformation
Returns the error message it recorded, if any, for the span status description.
"""
try:
from litellm.integrations._types.open_inference import (
@ -2253,10 +2277,10 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
span.record_exception(exception)
# Get StandardLoggingPayload for structured error information
standard_logging_payload: Final[StandardLoggingPayload | None] = kwargs.get("standard_logging_object")
standard_logging_payload: Final = kwargs.get("standard_logging_object")
if standard_logging_payload is None:
return
return None
# Extract error_information from StandardLoggingPayload
error_information: Final = standard_logging_payload.get("error_information")
@ -2270,59 +2294,67 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
key=ErrorAttributes.ERROR_MESSAGE,
value=error_str,
)
return
return _text_as_stored_on_span(span, ErrorAttributes.ERROR_MESSAGE, error_str) if error_str else None
# Set structured error attributes from StandardLoggingPayloadErrorInformation
if error_information.get("error_code"):
error_code: Final = error_information.get("error_code")
if error_code:
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_CODE,
value=error_information["error_code"],
value=error_code,
)
# Also expose under the OTel-standard name as an int
# (error_code is a str, may be non-numeric).
_error_code_val: Final = error_information["error_code"]
if _error_code_val is not None:
try:
self.safe_set_attribute(
span=span,
key=HTTP_RESPONSE_STATUS_CODE_ATTRIBUTE,
value=int(_error_code_val),
)
except (ValueError, TypeError):
pass
try:
self.safe_set_attribute(
span=span,
key=HTTP_RESPONSE_STATUS_CODE_ATTRIBUTE,
value=int(error_code),
)
except (ValueError, TypeError):
pass
if error_information.get("error_class"):
error_class: Final = error_information.get("error_class")
if error_class:
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_TYPE,
value=error_information["error_class"],
value=error_class,
)
if error_information.get("error_message"):
error_message: Final = error_information.get("error_message")
if error_message:
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_MESSAGE,
value=error_information["error_message"],
value=error_message,
)
if error_information.get("llm_provider"):
llm_provider: Final = error_information.get("llm_provider")
if llm_provider:
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_LLM_PROVIDER,
value=error_information["llm_provider"],
value=llm_provider,
)
if error_information.get("traceback"):
traceback_text: Final = error_information.get("traceback")
if traceback_text:
self.safe_set_attribute(
span=span,
key=ErrorAttributes.ERROR_STACK_TRACE,
value=error_information["traceback"],
value=traceback_text,
)
except Exception as e:
verbose_logger.exception("OpenTelemetry: Error recording exception on span: %s", str(e))
return None
else:
if error_message:
return _text_as_stored_on_span(span, ErrorAttributes.ERROR_MESSAGE, error_message)
return error_class or None
def set_tools_attributes(self, span: Span, tools):
import json
@ -3518,8 +3550,6 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
key="exception",
value=str(_exception),
)
management_endpoint_span.set_status(Status(StatusCode.ERROR))
management_endpoint_span.end(end_time=_end_time_ns)
# The management wrapper has no other hook that closes the SERVER span.
from litellm.litellm_core_utils.litellm_logging import (
@ -3529,14 +3559,16 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
error_information: Final = StandardLoggingPayloadSetup.get_error_information(
original_exception=_exception,
)
parent_otel_span.set_status(Status(StatusCode.ERROR))
self._record_exception_on_span(
server_error_message: Final = self._record_exception_on_span(
span=parent_otel_span,
kwargs={
"exception": _exception,
"standard_logging_object": {"error_information": error_information},
},
)
management_endpoint_span.set_status(Status(StatusCode.ERROR, server_error_message))
management_endpoint_span.end(end_time=_end_time_ns)
parent_otel_span.set_status(Status(StatusCode.ERROR, server_error_message))
parent_otel_span.end(end_time=_end_time_ns)
def create_litellm_proxy_request_started_span(
@ -3596,19 +3628,20 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
span: Span | None,
exception: Exception | None,
status_code: int,
) -> None:
) -> str | None:
"""Stamp structured ``error.*`` attributes on the SERVER span from the
exception returned to the client, with ``error.code`` pinned to the real
response status. Idempotent (overwrites); emits no exception event."""
response status. Idempotent (overwrites); emits no exception event.
Returns the error text for the span status description."""
if span is None or exception is None:
return
return None
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
error_information: Final = StandardLoggingPayloadSetup.get_error_information(original_exception=exception)
error_information["error_code"] = str(status_code)
self._record_exception_on_span(
return self._record_exception_on_span(
span=span,
kwargs={"standard_logging_object": {"error_information": error_information}},
)

View file

@ -91,7 +91,7 @@ way (`pod_lock`, `budget_reset`, `spend_queue`, `health_check`, `scheduler_queue
rather than a bare `redis.set`; `tests/unit/test_internal_context.py` scans every
module under `litellm/` and `enterprise/` that calls a shared cache or declares a
read or write on the request batch (`reserve_redis_batch_reads`,
`declare_batch_get`, `batch.mget`, `batch.set`, `batch.script`) and fails when
`batch.mget`, `batch.set`, `batch.script`) and fails when
one has no declared family, with the process-local `InMemoryCache` callers listed
as the only exemptions. A batch op carries the family that was active when it was
declared, so the routing prefetch armed before deployment selection

View file

@ -594,6 +594,7 @@ class OpenTelemetryV2(CustomLogger):
payload,
capture_content=self._capture_span_content(),
time_to_first_chunk_seconds=call.time_to_first_chunk_seconds,
completion_start_seconds=call.completion_start_seconds,
request_route=request_root_http_route(),
request_purpose=call.purpose,
trace=call.trace,

View file

@ -12,8 +12,10 @@ the JSON-serialized payloads. ``trace_attributes`` maps the caller's trace contr
import json
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Final
from litellm.integrations.langfuse.langfuse_otel_attributes import serialize_langfuse_datetime
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
from litellm.integrations.otel.mappers.utils import (
collect,
@ -45,6 +47,11 @@ class LangfuseMapper:
"langfuse.observation.id": lambda d: d.identity.call_id or None,
"langfuse.trace.metadata.team_id": lambda d: d.identity.team_id or None,
"langfuse.trace.metadata.team_alias": lambda d: d.identity.team_alias or None,
"langfuse.observation.completion_start_time": lambda d: (
serialize_langfuse_datetime(datetime.fromtimestamp(d.completion_start_seconds, tz=timezone.utc))
if d.completion_start_seconds is not None
else None
),
}
# Sub-tables folded into their respective JSON blobs.

View file

@ -258,6 +258,7 @@ class LLMCallEvent:
# needs to be reasonable for a span that never gets closed (a leak).
provisional_span_name: str
time_to_first_chunk_seconds: float | None
completion_start_seconds: float | None
trace: TraceControls
session_id: str | None
@ -280,6 +281,7 @@ class LLMCallEvent:
cache_hit=bool(payload.get("cache_hit")) if payload else False, # pyright: ignore[reportUnknownMemberType] # TypedDict .get on a str key is partially unknown
provisional_span_name=f"{operation.value} {model}".strip(),
time_to_first_chunk_seconds=time_to_first_chunk_seconds(kwargs),
completion_start_seconds=_epoch_seconds(kwargs.get("completion_start_time")),
trace=trace,
session_id=caller_session_id(kwargs, trace),
)

View file

@ -429,6 +429,7 @@ class LLMCallSpanData:
choices_out: tuple[Mapping[str, object], ...] = ()
system_fingerprint: str | None = None
time_to_first_chunk_seconds: float | None = None
completion_start_seconds: float | None = None
# The requested output modality, set only on the routes that pin one (image
# generation, speech, transcription, OCR), and the litellm route itself, which
# keeps routes the convention folds into one operation distinguishable.
@ -450,6 +451,7 @@ class LLMCallSpanData:
payload: StandardLoggingPayload,
capture_content: bool = False,
time_to_first_chunk_seconds: float | None = None,
completion_start_seconds: float | None = None,
request_route: str | None = None,
request_purpose: str | None = None,
trace: TraceControls | None = None,
@ -481,6 +483,7 @@ class LLMCallSpanData:
embedding_output: Final = (
EmbeddingOutput.from_response(response) if operation is GenAIOperation.EMBEDDINGS else None
)
cost_lookup_failed: Final = payload.get("response_cost_failure_debug_info") is not None and not cache_hit
return cls(
operation=operation,
provider=resolve_provider(as_str(payload.get("custom_llm_provider"))),
@ -491,7 +494,7 @@ class LLMCallSpanData:
usage=LLMUsage.from_standard_logging_payload(payload),
finish_reasons=finish_reasons,
error=_parse_error(payload),
response_cost=as_float(payload.get("response_cost")),
response_cost=None if cost_lookup_failed else as_float(payload.get("response_cost")),
cost=LLMCost.from_breakdown(cast("Mapping[str, object] | None", payload.get("cost_breakdown"))),
server=None if cache_hit else ServerInfo.from_api_base(context.api_base),
identity=context.identity,
@ -502,6 +505,7 @@ class LLMCallSpanData:
choices_out=choices_out if capture_content else (),
system_fingerprint=as_str(response.get("system_fingerprint")),
time_to_first_chunk_seconds=time_to_first_chunk_seconds,
completion_start_seconds=completion_start_seconds,
output_type=resolve_output_type(call_type),
call_type=call_type or None,
request_route=request_route or context.identity.request_route,

View file

@ -214,6 +214,7 @@ _SERVICE_VERB_BY_CALL_TYPE: Final[dict[str, str]] = {
"increment_cache": "incr",
"async_increment": "incr",
"async_increment_pipeline": "incr",
"async_increment_if_present": "incr",
"delete_cache": "delete",
"async_delete_cache": "delete",
"async_rpush": "rpush",

View file

@ -47,9 +47,11 @@ from litellm.litellm_core_utils.core_helpers import (
get_litellm_metadata_from_kwargs,
get_metadata_variable_name_from_kwargs,
)
from litellm.litellm_core_utils.get_llm_provider_logic import is_registered_custom_provider
from litellm.litellm_core_utils.service_tier_utils import (
get_service_tier_from_standard_logging_payload,
)
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.models.end_user import LiteLLM_EndUserTable
from litellm.models.team import LiteLLM_TeamTable
from litellm.models.user import LiteLLM_UserTable
@ -60,6 +62,7 @@ from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.table_repositories import EndUserRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.router_utils.router_callbacks.track_deployment_metrics import find_deployment_metadata
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.integrations.prometheus import *
from litellm.types.integrations.prometheus import (
@ -255,11 +258,32 @@ class _LabeledMetric:
_MetricLike: TypeAlias = "NoOpMetric | _LabeledMetric | MetricWrapperBase"
_SeriesLimitT: Final = TypeVar("_SeriesLimitT", int, float)
_REQUEST_METADATA_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
_POSITIVE_SERIES_CAP: Final[TypeAdapter[int]] = TypeAdapter(Annotated[int, Field(gt=0)])
_POSITIVE_SERIES_TTL: Final[TypeAdapter[float]] = TypeAdapter(Annotated[float, Field(gt=0)])
_SERIES_CLEANUP_INTERVAL: Final[TypeAdapter[float]] = TypeAdapter(Annotated[float, Field(ge=0)])
_DEFAULT_SERIES_CLEANUP_INTERVAL_SECONDS: Final = 60.0
_ADDITIONAL_HEADERS: Final[TypeAdapter[Mapping[object, object]]] = TypeAdapter(Mapping[object, object])
_LOGGED_FIELDS: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
_NO_LOGGED_FIELDS: Final[Mapping[str, object]] = MappingProxyType({})
def _logged_fields(value: object) -> Mapping[str, object]:
return _LOGGED_FIELDS.validate_python(value) if isinstance(value, Mapping) else _NO_LOGGED_FIELDS
def _is_llm_provider(name: str) -> bool:
routable: Final = (
name in litellm.provider_list or JSONProviderRegistry.exists(name) or is_registered_custom_provider(name)
)
return routable and name not in litellm._known_custom_logger_compatible_callbacks # pyright: ignore[reportPrivateUsage] # the router treats these prefixes as prompt-management deployments
def _deployment_model_provider(deployment: object) -> str:
if not isinstance(deployment, str):
return ""
prefix, separator, _ = deployment.partition("/")
return prefix if separator and _is_llm_provider(prefix) else ""
def _number_or_none(value: object, limit: TypeAdapter[_SeriesLimitT]) -> _SeriesLimitT | None:
@ -1725,12 +1749,25 @@ class PrometheusLogger(CustomLogger):
# increment litellm_proxy_total_requests_metric for all successful requests
# (both streaming and non-streaming) in this single location to prevent
# double-counting that occurs when async_post_call_success_hook also increments
request_metadata: Final = {
**_REQUEST_METADATA_ADAPTER.validate_python(get_litellm_metadata_from_kwargs(kwargs)),
**_REQUEST_METADATA_ADAPTER.validate_python(_metadata),
}
original_model_group: Final = request_metadata.get("original_model_group")
request_enum_values: Final = replace(
enum_values,
requested_model=(
original_model_group
if isinstance(original_model_group, str) and original_model_group
else standard_logging_payload["model_group"]
),
)
PrometheusLogger._inc_labeled_counter(
self,
self.litellm_proxy_total_requests_metric,
"litellm_proxy_total_requests_metric",
enum_values,
label_context=label_context,
request_enum_values,
label_context=PrometheusLabelFactoryContext(request_enum_values),
)
def _increment_token_metrics(
@ -2846,6 +2883,25 @@ class PrometheusLogger(CustomLogger):
)
return None
@staticmethod
def _extract_deployment_api_provider(request_kwargs: Mapping[str, object]) -> str:
"""
Provider of the deployment a failed call was routed to: the resolved
``custom_llm_provider`` on ``litellm_params``, then the one recorded on
``standard_logging_object``, then the provider prefix of the deployment
model the router wrote into the request metadata (``azure/my-gpt`` ->
``azure``) when that prefix is an LLM provider the router accepts.
Returns an empty string otherwise.
"""
litellm_params: Final = _logged_fields(request_kwargs.get("litellm_params"))
provider: Final = litellm_params.get("custom_llm_provider") or _logged_fields(
request_kwargs.get("standard_logging_object")
).get("custom_llm_provider")
if isinstance(provider, str) and provider:
return provider
deployment_metadata: Final = find_deployment_metadata(litellm_params)
return _deployment_model_provider(deployment_metadata.get("deployment") if deployment_metadata else None)
@staticmethod
def _extract_api_provider_from_exception(exception: Exception) -> str | None:
if not isinstance(exception, litellm.exceptions.RateLimitError):
@ -3124,7 +3180,9 @@ class PrometheusLogger(CustomLogger):
label_litellm_model_name = litellm_model_name
label_model_id = model_id
label_api_base = api_base
label_api_provider = llm_provider
label_api_provider = self._extract_deployment_api_provider(
_LOGGED_FIELDS.validate_python(request_kwargs)
)
label_requested_model = model_group or litellm_model_name
label_model_group = model_group
else:

View file

@ -5,7 +5,7 @@ Transforms between Anthropic/OpenAI tool_use format and LiteLLM search format.
"""
import json
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from typing import Final
from pydantic import TypeAdapter
@ -539,7 +539,8 @@ class WebSearchTransformation:
result: SearchResponse from litellm.asearch()
Returns:
Formatted text with Title, URL, Snippet for each result
Formatted text with Title, URL, Snippet for each result, or the response
without its provider_metadata (the caller's billing envelope) when there are no results
"""
# Convert SearchResponse to string
if hasattr(result, "results") and result.results:
@ -548,6 +549,9 @@ class WebSearchTransformation:
[f"Title: {r.title}\nURL: {r.url}\nSnippet: {r.snippet}" for r in result.results]
)
else:
search_result_text = str(result)
fields: Final[Mapping[str, object]] = dict(result)
search_result_text = " ".join(
f"{key}={value!r}" for key, value in fields.items() if key != "provider_metadata"
)
return search_result_text

View file

@ -11,6 +11,7 @@ from datetime import datetime
from typing import Any, Final
import httpx
from pydantic import ConfigDict, TypeAdapter
from litellm._logging import verbose_logger
from litellm.constants import STREAM_SSE_DONE_STRING
@ -25,6 +26,10 @@ from litellm.types.interactions import (
)
from litellm.utils import CustomStreamWrapper
_INTERACTIONS_STREAMING_CHUNK_ADAPTER: Final = TypeAdapter(
object, config=ConfigDict(strict=True, hide_input_in_errors=True)
)
class BaseInteractionsAPIStreamingIterator:
"""
@ -48,7 +53,7 @@ class BaseInteractionsAPIStreamingIterator:
self.finished = False
self.interactions_api_config = interactions_api_config
self.completed_response: InteractionsAPIStreamingResponse | None = None
self.start_time = datetime.now()
self.start_time = logging_obj.start_time
# set request kwargs
self.litellm_metadata = litellm_metadata
@ -83,10 +88,12 @@ class BaseInteractionsAPIStreamingIterator:
try:
# Parse the JSON chunk
parsed_chunk: Final = json.loads(stripped_chunk)
parsed_chunk: Final = _INTERACTIONS_STREAMING_CHUNK_ADAPTER.validate_python(json.loads(stripped_chunk))
# Format as InteractionsAPIStreamingResponse
if isinstance(parsed_chunk, dict):
if self.logging_obj.completion_start_time is None:
self.logging_obj.update_completion_start_time(completion_start_time=datetime.now())
streaming_response: Final = self.interactions_api_config.transform_streaming_response(
model=self.model,
parsed_chunk=parsed_chunk,

View file

@ -1,6 +1,7 @@
import posixpath
import re
from collections.abc import Mapping, Sequence
from itertools import accumulate, repeat
from types import MappingProxyType
from typing import Any, Final, cast
from urllib.parse import quote, unquote
@ -20,15 +21,33 @@ MANAGED_CLOUD_STORAGE_SCHEMES: Final = ("s3://", "gs://")
_MAPPING_PROXY_TYPE: Final[type] = type(MappingProxyType({}))
MAX_FILE_ID_DECODE_PASSES: Final = 8
def _unquote_once(current: str, _: str) -> str:
return unquote(current)
def _decodings(value: str) -> tuple[str, ...]:
return tuple(accumulate(repeat(value, MAX_FILE_ID_DECODE_PASSES + 2), _unquote_once))
def is_managed_cloud_storage_uri(file_id: str) -> bool:
"""
True if file_id is a raw cloud-storage object URI (e.g. ``s3://bucket/key``).
True if file_id is a raw cloud-storage object URI (e.g. ``s3://bucket/key``),
up to ``MAX_FILE_ID_DECODE_PASSES`` layers of percent-encoding deep, or an id
encoded deeper than that, which no client produces and which is treated as raw
rather than decoded any further.
These are internal provider artifacts. On the multi-tenant proxy they must be
retrieved through their managed unified file id so owner/team access is enforced;
a raw URI supplied by a caller bypasses that check.
"""
return isinstance(file_id, str) and file_id.startswith(MANAGED_CLOUD_STORAGE_SCHEMES)
if not isinstance(file_id, str):
return False
decodings: Final = _decodings(file_id)
settled: Final = decodings[-1] == decodings[-2]
return decodings[-1].startswith(MANAGED_CLOUD_STORAGE_SCHEMES) or not settled
_SAFE_OBJECT_COMPONENT_PATTERN: Final = re.compile(r"[^A-Za-z0-9._-]+")

View file

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, runtime_c
import httpx
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_logger
from litellm.constants import KUBERNETES_POD_ROUTING_KEY
from litellm.litellm_core_utils.llm_response_utils.get_headers import get_provider_request_id
@ -93,6 +94,10 @@ def normalize_drop_params(value: object) -> bool | None:
return None
def drops_unsupported_params(drop_params: object) -> bool:
return litellm.drop_params is True or normalize_drop_params(drop_params) is True
def drop_params_flag(value: object, source: str, logger: logging.Logger) -> bool:
normalized: Final = normalize_drop_params(value)
if normalized is None and value is not None:
@ -515,7 +520,7 @@ def reconstruct_model_name(
# Helper functions used for OTEL logging
def get_parent_otel_span_from_kwargs(
kwargs: dict[str, object] | None = None,
kwargs: Mapping[str, object] | None = None,
) -> Span | None:
try:
if kwargs is None:
@ -607,10 +612,30 @@ def preserve_upstream_non_openai_attributes(
Preserve non-OpenAI attributes from the original chunk.
"""
# Access model_fields on the class, not the instance, to avoid Pydantic 2.11+ deprecation warnings
expected_keys: Final = set(type(model_response).model_fields.keys()).union({"usage"})
for key, value in original_chunk.model_dump().items():
if key not in expected_keys:
expected_keys: Final = frozenset(type(model_response).model_fields) | frozenset({"usage"})
candidate_keys: Final = tuple(
key
for key in (*type(original_chunk).model_fields, *(original_chunk.__pydantic_extra__ or {}))
if key not in expected_keys
)
if not candidate_keys:
return
candidate_values: Final[tuple[tuple[str, object], ...]] = tuple(
(
key,
cast( # cast-ok: Pydantic extras are scalar-checked before assignment
object,
getattr(original_chunk, key),
),
)
for key in candidate_keys
)
if all(value is None or isinstance(value, (str, int, float, bool)) for _, value in candidate_values):
for key, value in candidate_values:
setattr(model_response, key, value)
return
for key, value in original_chunk.model_dump(include=frozenset(candidate_keys)).items():
setattr(model_response, key, value)
def safe_deep_copy(data):

View file

@ -1,9 +1,15 @@
"""Utils for accessing credentials."""
from collections.abc import Mapping
from typing import Final
import litellm
from litellm.types.utils import CredentialItem
from litellm.types.router import holds_api_key_secret_reference
from litellm.types.utils import (
API_KEY_SECRET_REFERENCE_REJECTION,
CredentialItem,
credential_api_key_secret_reference,
)
class CredentialAccessor:
@ -21,7 +27,7 @@ class CredentialAccessor:
"""Safe accessor for credentials."""
credential: Final = CredentialAccessor.find_credential(credential_name)
return {} if credential is None else credential.credential_values.copy()
return {} if credential is None else dict(credential_api_key_secret_reference(credential.credential_values))
@staticmethod
def upsert_credentials(credentials: list[CredentialItem]):
@ -38,3 +44,20 @@ class CredentialAccessor:
break
else:
litellm.credential_list.append(credential)
def sends_api_key_secret_reference(params: Mapping[str, object]) -> bool:
named: Final = params.get("litellm_credential_name")
credential_values: Final = CredentialAccessor.get_credential_values(named) if isinstance(named, str) else {}
return holds_api_key_secret_reference({**credential_values, **params})
def reject_api_key_secret_reference(params: Mapping[str, object]) -> None:
if holds_api_key_secret_reference(params):
raise ValueError(API_KEY_SECRET_REFERENCE_REJECTION)
named: Final = params.get("litellm_credential_name")
if isinstance(named, str) and holds_api_key_secret_reference(CredentialAccessor.get_credential_values(named)):
raise ValueError(
f"Rejected Request: litellm_credential_name={named!r} names a credential holding an oidc/ api_key "
"secret reference, which only a deployment a proxy admin configures can attach."
)

View file

@ -27,6 +27,7 @@ AWS_CREDENTIAL_KWARGS_KEYS: Final = frozenset(
"aws_external_id",
"aws_session_tags",
"aws_bedrock_runtime_endpoint",
"aws_bedrock_control_plane_endpoint",
"aws_bedrock_project_id",
}
)

View file

@ -558,11 +558,14 @@ def get_model_cost_map_loaded_at() -> "datetime | None":
class AliasedCostEntry(dict[str, object]):
__slots__ = ("names",)
__slots__ = ("names", "registered_fields")
def __init__(self, entry: Mapping[str, object], names: tuple[str, ...]) -> None:
def __init__(
self, entry: Mapping[str, object], names: tuple[str, ...], registered_fields: frozenset[str] = frozenset()
) -> None:
super().__init__(entry)
self.names: tuple[str, ...] = names
self.registered_fields: frozenset[str] = registered_fields
def _alias_groups(alias_owners: Mapping[str, str]) -> Mapping[str, tuple[str, ...]]:

View file

@ -1,6 +1,8 @@
from collections.abc import Iterator, MutableMapping
from collections.abc import Iterator, Mapping, MutableMapping
from typing import Final, Protocol, cast
from pydantic import BaseModel
from litellm.types.llms.base import HiddenParams
_HIDDEN_PARAMS_ATTR: Final = "_hidden_params"
@ -47,8 +49,22 @@ class HiddenParamsModelView(MutableMapping[str, object]):
return frozenset(self._hidden_params.model_fields_set) | frozenset(self._hidden_params.model_extra or {})
def stored_attribute(obj: object, name: str) -> object:
if not isinstance(obj, BaseModel):
return getattr(obj, name, None)
values: Final[Mapping[str, object]] = vars(obj)
if name in values:
return values[name]
extras: Final[Mapping[str, object]] = obj.__pydantic_extra__ or {}
return extras.get(name)
def _get_hidden_params_storage(obj: object) -> object | None:
return obj.get(_HIDDEN_PARAMS_ATTR) if isinstance(obj, dict) else getattr(obj, _HIDDEN_PARAMS_ATTR, None)
if isinstance(obj, dict):
return obj.get(_HIDDEN_PARAMS_ATTR)
if isinstance(obj, BaseModel) and _HIDDEN_PARAMS_ATTR in type(obj).__private_attributes__:
return getattr(obj, _HIDDEN_PARAMS_ATTR, None)
return stored_attribute(obj, _HIDDEN_PARAMS_ATTR)
def get_hidden_params_storage(obj: object) -> dict[str, object] | HiddenParams | None:

View file

@ -44,6 +44,7 @@ from litellm.constants import (
EMPTY_MAPPING,
INTERNAL_CALLBACK_NAMES,
KUBERNETES_POD_ROUTING_KEY,
LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL,
PROVIDER_REQUEST_ID_HEADERS,
REDACTED_BY_LITELLM,
)
@ -116,7 +117,8 @@ from litellm.litellm_core_utils.ptu_pricing import is_spilled_over_ptu_request
from litellm.litellm_core_utils.redact_messages import (
redact_message_input_output_from_custom_logger,
redact_message_input_output_from_logging,
redact_streaming_responses_for_custom_logger,
redacted_model_call_details_for_custom_logger,
redacted_response_obj_for_custom_logger,
should_redact_message_logging,
)
from litellm.litellm_core_utils.served_output_texts import (
@ -336,6 +338,7 @@ def _get_provider_request_id(original_exception: Exception) -> str | None:
# Cache custom pricing keys as frozenset for O(1) lookups instead of looping through 49 keys
_CUSTOM_PRICING_KEYS: Final[frozenset[str]] = frozenset(CustomPricingLiteLLMParams.model_fields.keys())
_METADATA_MAPPING: Final = TypeAdapter(Mapping[str, object])
_MODEL_INFO_CUSTOM_PRICING_KEYS: Final[frozenset[str]] = _CUSTOM_PRICING_KEYS | DEPLOYMENT_SCOPED_PRICING_FIELDS
_UNSERIALIZABLE_METADATA_KEYS: Final[frozenset[str]] = frozenset(
("user_api_key_auth", "user_api_key_budget_reservation")
@ -508,7 +511,7 @@ def _published_pricing(deployment_model: str | None) -> ModelInfo | None:
return None
def _resolve_vertex_location_for_cost(
def resolve_vertex_location_for_cost(
custom_llm_provider: str | None,
litellm_params: Mapping[str, object] | None,
optional_params: Mapping[str, object] | None,
@ -994,6 +997,21 @@ class Logging(LiteLLMLoggingBaseClass):
return model_id
return None
def get_router_pricing_provider(self) -> str | None:
"""The deployment's ``model_info.pricing_provider``, the provider its cost-map lookups run under."""
litellm_params: Final = cast( # cast-ok: callback metadata is caller-provided
Mapping[str, object] | None, getattr(self, "litellm_params", None)
)
if not litellm_params:
return None
candidates: Final = (
_METADATA_MAPPING.validate_python(
_METADATA_MAPPING.validate_python(litellm_params.get(key) or {}).get("model_info") or {}
).get("pricing_provider")
for key in ("litellm_metadata", "metadata")
)
return next((provider for provider in candidates if isinstance(provider, str) and provider), None)
def _routed_deployment_model(self) -> str | None:
litellm_params: Final = self._model_call_details_view.get("litellm_params")
metadata: Final = (
@ -1531,15 +1549,20 @@ class Logging(LiteLLMLoggingBaseClass):
"LiteLLM.LoggingError: [Non-Blocking] Exception occurred while logging %s", e
)
self.model_call_details["api_call_start_time"] = datetime.datetime.now()
# Set-once first provider-handoff instant. api_call_start_time
# is overwritten on every retry, so it can't measure one-time
# preprocessing; pinning the first attempt excludes retry loops
# + backoff. Logging object only — must NOT go into
# litellm_params["metadata"] (caller request metadata, typed
# Dict[str, str], echoed downstream; a datetime breaks it).
if self.model_call_details.get("first_api_call_start_time") is None:
self.model_call_details["first_api_call_start_time"] = self.model_call_details["api_call_start_time"]
# A proxy-gate failure (auth, rate limit, client disconnect) fires pre_call for its callbacks
# without any provider handoff, so it must not stamp the handoff instants.
if LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL not in self.model_call_details:
self.model_call_details["api_call_start_time"] = datetime.datetime.now()
# Set-once first provider-handoff instant. api_call_start_time
# is overwritten on every retry, so it can't measure one-time
# preprocessing; pinning the first attempt excludes retry loops
# + backoff. Logging object only — must NOT go into
# litellm_params["metadata"] (caller request metadata, typed
# Dict[str, str], echoed downstream; a datetime breaks it).
if self.model_call_details.get("first_api_call_start_time") is None:
self.model_call_details["first_api_call_start_time"] = self.model_call_details[
"api_call_start_time"
]
# Input Integration Logging -> If you want to log the fact that an attempt to call the model was made
callbacks: Final = litellm.input_callback + (self.dynamic_input_callbacks or [])
for callback in callbacks:
@ -1933,6 +1956,8 @@ class Logging(LiteLLMLoggingBaseClass):
used for consistent cost calculation across response headers + logging integrations.
"""
self.model_call_details.pop("response_cost_failure_debug_information", None)
if cache_hit is None:
cache_hit = self.model_call_details.get("cache_hit", False)
@ -1982,7 +2007,7 @@ class Logging(LiteLLMLoggingBaseClass):
router_model_id = self.get_router_model_id()
## RESPONSE COST ##
custom_pricing: Final = self._custom_pricing_for(priced_result)
custom_pricing: Final = self.custom_pricing_for(priced_result)
prompt: Final = self._prompt_for_cost_calculation()
@ -2012,13 +2037,14 @@ class Logging(LiteLLMLoggingBaseClass):
"prompt": prompt,
"standard_built_in_tools_params": self.standard_built_in_tools_params,
"router_model_id": router_model_id,
"pricing_provider": self.get_router_pricing_provider(),
"litellm_logging_obj": self,
"data_residency": (
self.litellm_params.get("data_residency")
if hasattr(self, "litellm_params") and self.litellm_params
else None
),
"vertex_location": _resolve_vertex_location_for_cost(
"vertex_location": resolve_vertex_location_for_cost(
custom_llm_provider=cost_custom_llm_provider,
litellm_params=(self.litellm_params if hasattr(self, "litellm_params") else None),
optional_params=self.optional_params,
@ -2093,15 +2119,17 @@ class Logging(LiteLLMLoggingBaseClass):
previous_cost_breakdown: Final = self.cost_breakdown
previous_billed_token_rates: Final = self.billed_token_rates
debug_key: Final = "response_cost_failure_debug_information"
had_failure_debug_info: Final = debug_key in self.model_call_details
previous_failure_debug_info: Final = self.model_call_details.get(debug_key)
try:
cost: Final = self.response_cost_calculator(result=result, cache_hit=False, ignore_stamped_cost=True)
return UncachedPrice(cost=cost, cost_breakdown=self.cost_breakdown)
finally:
self.cost_breakdown = previous_cost_breakdown
self.billed_token_rates = previous_billed_token_rates
if not had_failure_debug_info:
if previous_failure_debug_info is None:
self.model_call_details.pop(debug_key, None)
else:
self.model_call_details[debug_key] = previous_failure_debug_info
def _record_zero_cost_diagnostic(
self,
@ -2154,8 +2182,8 @@ class Logging(LiteLLMLoggingBaseClass):
pricing: Final = pricing_entry_for_cost_calc(
model=model,
completion_response=result,
custom_llm_provider=custom_llm_provider,
custom_pricing=self._custom_pricing_for(result),
custom_llm_provider=self.get_router_pricing_provider() or custom_llm_provider,
custom_pricing=self.custom_pricing_for(result),
base_model=get_base_model_from_metadata(model_call_details=self.model_call_details),
router_model_id=router_model_id or self.get_router_model_id(),
region_name=_resolve_mantle_region_for_cost(
@ -2180,7 +2208,7 @@ class Logging(LiteLLMLoggingBaseClass):
usage=usage,
)
def _custom_pricing_for(self, result: object) -> bool:
def custom_pricing_for(self, result: object) -> bool:
litellm_params: Final = getattr(self, "litellm_params", None)
result_hidden_params: Final = getattr(result, HIDDEN_PARAMS_ATTR, None) or MappingProxyType({})
additional_headers: Final = (
@ -3479,8 +3507,10 @@ class Logging(LiteLLMLoggingBaseClass):
): # custom logger class
if self.stream and complete_streaming_response is None:
callback.log_stream_event(
kwargs=call_details,
response_obj=result,
kwargs=redacted_model_call_details_for_custom_logger(
model_call_details=call_details, custom_logger=callback
),
response_obj=redacted_response_obj_for_custom_logger(result, callback),
start_time=start_time,
end_time=end_time,
)
@ -3490,8 +3520,13 @@ class Logging(LiteLLMLoggingBaseClass):
result = call_details_view["complete_response"]
callback.log_success_event(
kwargs=callback.redact_standard_logging_payload_from_model_call_details(call_details),
response_obj=result,
kwargs=redacted_model_call_details_for_custom_logger(
model_call_details=callback.redact_standard_logging_payload_from_model_call_details(
call_details
),
custom_logger=callback,
),
response_obj=redacted_response_obj_for_custom_logger(result, callback),
start_time=start_time,
end_time=end_time,
)
@ -3605,6 +3640,14 @@ class Logging(LiteLLMLoggingBaseClass):
model_name=self.get_deployment_model_for_cost(),
litellm_params=self.litellm_params,
model_info=self.get_router_deployment_model_info(),
vertex_location=resolve_vertex_location_for_cost(
custom_llm_provider=self.custom_llm_provider,
litellm_params=_LITELLM_PARAMS.validate_python(
self.litellm_params # pyright: ignore[reportUnknownMemberType] # Logging.litellm_params is untyped
),
optional_params=None,
model=self.get_deployment_model_for_cost() or self.model,
),
)
set_hidden_param(result, "response_cost", batch_result.cost)
@ -3826,7 +3869,7 @@ class Logging(LiteLLMLoggingBaseClass):
model_call_details = callback.redact_standard_logging_payload_from_model_call_details(
model_call_details=model_call_details
)
model_call_details = redact_streaming_responses_for_custom_logger(
model_call_details = redacted_model_call_details_for_custom_logger(
model_call_details=model_call_details, custom_logger=callback
)
##################################
@ -3837,7 +3880,7 @@ class Logging(LiteLLMLoggingBaseClass):
kwargs=model_call_details,
response_obj=model_call_details["async_complete_streaming_response"]
if self.stream
else result,
else redacted_response_obj_for_custom_logger(result, callback),
start_time=start_time,
end_time=end_time,
guardrail_snapshot=self.shadow_eval_request_snapshot,
@ -3853,14 +3896,14 @@ class Logging(LiteLLMLoggingBaseClass):
else:
await callback.async_log_stream_event( # [TODO]: move this to being an async log stream event function
kwargs=model_call_details,
response_obj=result,
response_obj=redacted_response_obj_for_custom_logger(result, callback),
start_time=start_time,
end_time=end_time,
)
else:
await callback.async_log_success_event(
kwargs=model_call_details,
response_obj=result,
response_obj=redacted_response_obj_for_custom_logger(result, callback),
start_time=start_time,
end_time=end_time,
)
@ -6471,6 +6514,7 @@ class StandardLoggingPayloadSetup:
custom_llm_provider: str | None,
init_response_obj: object,
api_base: str | None = None,
router_model_id: str | None = None,
) -> StandardLoggingModelInformation:
model_cost_name: Final = select_model_name_for_cost_calc(
model=base_model if custom_pricing else None,
@ -6478,12 +6522,23 @@ class StandardLoggingPayloadSetup:
base_model=base_model,
custom_pricing=custom_pricing,
)
pricing_model_name: Final = (
select_model_name_for_cost_calc(
model=base_model if custom_pricing else None,
completion_response=init_response_obj,
base_model=base_model,
custom_pricing=custom_pricing,
router_model_id=router_model_id,
)
if router_model_id is not None
else model_cost_name
)
if model_cost_name is None:
model_cost_information = StandardLoggingModelInformation(model_map_key="", model_map_value=None)
else:
try:
_model_cost_information: Final = litellm.get_model_info(
model=model_cost_name,
model=pricing_model_name or model_cost_name,
custom_llm_provider=custom_llm_provider,
api_base=api_base,
)
@ -7171,7 +7226,7 @@ def get_standard_logging_object_payload(
# Fall back to the actual deployment model set by the router in metadata.
if base_model is None:
base_model = metadata.get("deployment")
custom_pricing: Final = use_custom_pricing_for_model(litellm_params=litellm_params)
custom_pricing: Final = logging_obj.custom_pricing_for(init_response_obj)
raw_response_cost: Final = kwargs.get("response_cost")
llm_response_cost: Final[float] = raw_response_cost or 0.0
guardrail_cost: Final = guardrail_information_cost(metadata.get("standard_logging_guardrail_information"))
@ -7193,6 +7248,7 @@ def get_standard_logging_object_payload(
custom_llm_provider=kwargs.get("custom_llm_provider"),
init_response_obj=init_response_obj,
api_base=litellm_params.get("api_base"),
router_model_id=logging_obj.get_router_model_id(),
)
error_information, error_str = StandardLoggingPayloadSetup.get_error_information_for_logging_payload(

View file

@ -285,7 +285,7 @@ def _parse_token_threshold(threshold: str) -> float:
def _parse_above_token_threshold(key: str) -> float:
return _parse_token_threshold(key.split("_above_")[1].split("_tokens")[0])
return _parse_token_threshold(key.rsplit("_above_", 1)[1].split("_tokens")[0])
def _prompt_exceeds_threshold(prompt_tokens: int, threshold: float, inclusive: bool) -> bool:
@ -356,7 +356,7 @@ def get_batch_cost_rates(model_info: ModelInfo, usage: Usage, custom_llm_provide
)
def _select_priced_tier(model_info: ModelInfo, usage: Usage) -> dict | None:
def _select_priced_tier(model_info: ModelInfo, usage: Usage) -> dict[str, object] | None:
tiered_pricing: Final = model_info.get("tiered_pricing")
if not isinstance(tiered_pricing, list) or not tiered_pricing:
return None
@ -545,6 +545,71 @@ class TokenRates:
return self.output_rate if self.reasoning_rate is None else self.reasoning_rate
def resolve_explicit_cache_read_cost_rate(
model_info: ModelInfo,
usage: Usage,
current_time: datetime | None,
service_tier: str | None = None,
) -> float | None:
cache_read_cost_key: Final = "explicit_cache_read_input_token_cost"
service_cache_read_cost_key: Final = _get_service_tier_cost_key(cache_read_cost_key, service_tier)
if (
model_info.get(service_cache_read_cost_key) is None
and model_info.get(cache_read_cost_key) is None
and model_info.get("tiered_pricing") is None
and model_info.get("off_peak_pricing") is None
):
return None
prompt_tokens_details: Final = usage.prompt_tokens_details
cache_type: Final = (
getattr(prompt_tokens_details, "cache_type", None) if prompt_tokens_details is not None else None
)
if parse_prompt_tokens_details(usage)["cache_hit_tokens"] <= 0 or cache_type != "ephemeral":
return None
tier: Final = _select_priced_tier(model_info=model_info, usage=usage)
off_peak: Final = _open_off_peak_block(model_info, current_time)
model_cache_read_cost_rate: Final = _get_cost_per_unit(
model_info,
service_cache_read_cost_key,
default_value=None,
)
tier_has_explicit_rate: Final = tier is not None and cache_read_cost_key in tier
off_peak_has_explicit_rate: Final = (
off_peak is not None and _parse_off_peak_rate(off_peak.get(cache_read_cost_key)) is not None
)
if model_cache_read_cost_rate is None and not tier_has_explicit_rate and not off_peak_has_explicit_rate:
return None
cache_read_cost_rate: Final = (
tier_rate(tier, cache_read_cost_key)
if tier is not None and tier_has_explicit_rate
else tier_rate(tier, "cache_read_input_token_cost", "input_cost_per_token")
if tier is not None
else model_cache_read_cost_rate
if model_cache_read_cost_rate is not None
else _get_cost_per_unit(
model_info,
_get_service_tier_cost_key("cache_read_input_token_cost", service_tier),
default_value=_get_cost_per_unit(model_info, "input_cost_per_token") or 0.0,
)
or 0.0
)
if off_peak is None:
return cache_read_cost_rate
mode_specific_off_peak_rate: Final = _parse_off_peak_rate(off_peak.get(cache_read_cost_key))
generic_off_peak_rate: Final = _parse_off_peak_rate(off_peak.get("cache_read_input_token_cost"))
return (
mode_specific_off_peak_rate
if mode_specific_off_peak_rate is not None
else generic_off_peak_rate
if generic_off_peak_rate is not None
else cache_read_cost_rate
)
def _parse_off_peak_rate(value: object) -> float | None:
if isinstance(value, bool):
return None
@ -683,10 +748,12 @@ def _get_token_base_cost(
# Optimization: collect threshold keys first to avoid sorting all model_info keys.
# Standard thresholds and thresholds suffixed for this request's service tier both count.
tier_key_suffix: Final = get_service_tier_cost_key("", service_tier)
threshold_prefixes: Final = tuple(f"{prefix}_above_" for prefix in _BATCH_RATE_PREFIXES)
threshold_keys: Final = [
k
for k in model_info
if k.startswith("input_cost_per_token_above_")
if k.startswith(threshold_prefixes)
and "_tokens" in k
and (
not k.endswith(_NON_STANDARD_THRESHOLD_SUFFIXES) or (tier_key_suffix != "" and k.endswith(tier_key_suffix))
)
@ -694,12 +761,12 @@ def _get_token_base_cost(
# Only sort the threshold keys (typically 1-2 keys instead of 66+)
threshold: float | None = None
for key in sorted(threshold_keys, key=_parse_above_token_threshold, reverse=True):
for key in sorted(threshold_keys, key=_parse_above_token_threshold):
value = model_info.get(key)
if value is not None:
try:
# Handle both formats: _above_128k_tokens and _above_128_tokens
threshold_str = key.split("_above_")[1].split("_tokens")[0]
threshold_str = key.rsplit("_above_", 1)[1].split("_tokens")[0]
threshold = _parse_above_token_threshold(key)
if _prompt_exceeds_threshold(usage.prompt_tokens, threshold, threshold_is_inclusive):
# Prefer a service_tier-specific above-threshold key when available,
@ -712,7 +779,7 @@ def _get_token_base_cost(
service_tier,
)
if service_tier
else key
else f"input_cost_per_token_above_{threshold_str}_tokens"
)
prompt_base_cost = cast(
float,
@ -768,8 +835,6 @@ def _get_token_base_cost(
)
cache_read_cost = get_cost_per_unit(model_info, cache_read_tiered_key, cache_read_cost)
break
except (IndexError, ValueError):
continue
except Exception:
@ -1088,6 +1153,25 @@ def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResu
)
def _modality_token_cost(
model_info: ModelInfo,
modality_cost_key: str,
tokens: int,
prompt_base_cost: float,
service_tier: str | None,
) -> float:
"""
Audio, image and video prompt tokens bill at their modality rate when the model has one.
Without one they are ordinary prompt tokens, so they bill at `prompt_base_cost`, which
`_get_token_base_cost` has already resolved for the service tier and long-context tier.
"""
tiered_key: Final = get_service_tier_cost_key(modality_cost_key, service_tier)
cost_key: Final = tiered_key if model_info.get(tiered_key) is not None else modality_cost_key
if model_info.get(cost_key) is None:
return float(tokens) * prompt_base_cost
return calculate_cost_component(model_info, cost_key, tokens)
def _calculate_input_cost(
prompt_tokens_details: PromptTokensDetailsResult,
model_info: ModelInfo,
@ -1099,8 +1183,14 @@ def _calculate_input_cost(
) -> float:
"""
Calculates the input cost for a given model, prompt tokens, and completion tokens.
A model priced per character (`input_cost_per_character`) bills the reported character count
in place of its text tokens, so the same prompt is never charged under both units.
"""
prompt_cost = float(prompt_tokens_details["text_tokens"]) * prompt_base_cost
bills_characters: Final = (
prompt_tokens_details["character_count"] > 0 and model_info.get("input_cost_per_character") is not None
)
prompt_cost = 0.0 if bills_characters else float(prompt_tokens_details["text_tokens"]) * prompt_base_cost
### CACHE READ COST - Now uses tiered pricing
cache_hit_audio_tokens: Final = prompt_tokens_details["cache_hit_audio_tokens"]
@ -1118,28 +1208,37 @@ def _calculate_input_cost(
if prompt_tokens_details["audio_tokens"] and not (
prompt_tokens_details["audio_length_seconds"] and model_info.get("input_cost_per_audio_per_second") is not None
):
audio_cost_key: Final = get_service_tier_cost_key("input_cost_per_audio_token", service_tier)
prompt_cost += calculate_cost_component(model_info, audio_cost_key, prompt_tokens_details["audio_tokens"])
prompt_cost += _modality_token_cost(
model_info,
"input_cost_per_audio_token",
prompt_tokens_details["audio_tokens"],
prompt_base_cost,
service_tier,
)
### IMAGE TOKEN COST
if prompt_tokens_details["image_tokens"] and not (
prompt_tokens_details["image_count"] and model_info.get("input_cost_per_image") is not None
):
# For image token costs:
# First check if input_cost_per_image_token is available. If not, default to generic input_cost_per_token.
image_token_cost_key = "input_cost_per_image_token"
if model_info.get(image_token_cost_key) is None:
image_token_cost_key = "input_cost_per_token"
prompt_cost += calculate_cost_component(model_info, image_token_cost_key, prompt_tokens_details["image_tokens"])
prompt_cost += _modality_token_cost(
model_info,
"input_cost_per_image_token",
prompt_tokens_details["image_tokens"],
prompt_base_cost,
service_tier,
)
### VIDEO TOKEN COST
if prompt_tokens_details["video_tokens"] and not (
prompt_tokens_details["video_length_seconds"] and model_info.get("input_cost_per_video_per_second") is not None
):
video_token_cost_key = "input_cost_per_video_token"
if model_info.get(video_token_cost_key) is None:
video_token_cost_key = "input_cost_per_token"
prompt_cost += calculate_cost_component(model_info, video_token_cost_key, prompt_tokens_details["video_tokens"])
prompt_cost += _modality_token_cost(
model_info,
"input_cost_per_video_token",
prompt_tokens_details["video_tokens"],
prompt_base_cost,
service_tier,
)
### CACHE WRITING COST - Now uses tiered pricing
if (
@ -1424,12 +1523,19 @@ def generic_cost_per_token(
current_time=billing_time,
threshold_is_inclusive=_uses_inclusive_token_thresholds(custom_llm_provider),
)
resolved_cache_read_cost: Final = resolve_explicit_cache_read_cost_rate(
model_info=resolved_model_info,
usage=usage,
current_time=billing_time,
service_tier=service_tier,
)
effective_cache_read_cost: Final = cache_read_cost if resolved_cache_read_cost is None else resolved_cache_read_cost
prompt_cost = _calculate_input_cost(
prompt_tokens_details=prompt_tokens_details,
model_info=resolved_model_info,
prompt_base_cost=prompt_base_cost,
cache_read_cost=cache_read_cost,
cache_read_cost=effective_cache_read_cost,
cache_creation_cost=cache_creation_cost,
cache_creation_cost_above_1hr=cache_creation_cost_above_1hr,
service_tier=service_tier,
@ -1624,6 +1730,16 @@ def _cost_map_billed_rates(
current_time=billing_time,
threshold_is_inclusive=_uses_inclusive_token_thresholds(custom_llm_provider),
)
provider_cache_read_cost_rate: Final = resolve_explicit_cache_read_cost_rate(
model_info=model_info,
usage=usage,
current_time=billing_time,
service_tier=service_tier,
)
effective_cache_read_cost_rate: Final = (
cache_read_cost_rate if provider_cache_read_cost_rate is None else provider_cache_read_cost_rate
)
reasoning_rate: Final = _resolve_billed_reasoning_rate(
model_info=model_info,
usage=usage,
@ -1645,9 +1761,9 @@ def _cost_map_billed_rates(
return BilledTokenRates(
input_cost_per_token=prompt_base_cost,
output_cost_per_token=completion_base_cost,
cache_read_input_token_cost=cache_read_cost_rate,
cache_read_input_token_cost=effective_cache_read_cost_rate,
cache_read_input_audio_token_cost=(
audio_cache_read_rate if audio_cache_read_rate is not None else cache_read_cost_rate
audio_cache_read_rate if audio_cache_read_rate is not None else effective_cache_read_cost_rate
),
cache_creation_input_token_cost=cache_creation_cost_rate,
cache_creation_input_token_cost_above_1hr=cache_creation_cost_above_1hr_rate,
@ -1664,20 +1780,23 @@ def get_billed_token_rates(
vertex_location: str | None = None,
current_time: datetime | None = None,
custom_cost_per_token: CostPerToken | None = None,
model_info: ModelInfo | None = None,
) -> BilledTokenRates | None:
"""Rates the cost calculator bills ``usage`` at, resolved exactly as the totals and the token-type
breakdown resolve them. None when the model's pricing cannot be resolved."""
if custom_cost_per_token is not None:
return _custom_pricing_rates(custom_cost_per_token)
try:
model_info: Final = apply_provider_cache_read_default(
get_model_info(model=model, custom_llm_provider=custom_llm_provider),
resolved_model_info: Final = apply_provider_cache_read_default(
model_info
if model_info is not None
else get_model_info(model=model, custom_llm_provider=custom_llm_provider),
custom_llm_provider,
)
except Exception: # noqa: BLE001 # get_model_info raises a bare Exception for an unmapped model: no rates
return None
return _cost_map_billed_rates(
model_info=model_info,
model_info=resolved_model_info,
usage=usage,
custom_llm_provider=custom_llm_provider,
service_tier=service_tier,
@ -1696,6 +1815,7 @@ def get_token_type_cost_breakdown(
vertex_location: str | None = None,
current_time: datetime | None = None,
custom_cost_per_token: CostPerToken | None = None,
model_info: ModelInfo | None = None,
) -> TokenTypeCostBreakdown:
"""
Provider-agnostic cost of reasoning and cache tokens, derived from the usage
@ -1720,6 +1840,7 @@ def get_token_type_cost_breakdown(
vertex_location=vertex_location,
current_time=current_time,
custom_cost_per_token=custom_cost_per_token,
model_info=model_info,
)
if rates is None:
return TokenTypeCostBreakdown(0.0, 0.0, 0.0)
@ -1772,6 +1893,13 @@ def calculate_prompt_caching_savings(
current_time=billed_at,
threshold_is_inclusive=_uses_inclusive_token_thresholds(custom_llm_provider),
)
provider_cache_read_cost: Final = resolve_explicit_cache_read_cost_rate(
model_info=model_info,
usage=usage,
current_time=billed_at,
service_tier=service_tier,
)
effective_cache_read_cost: Final = cache_read_cost if provider_cache_read_cost is None else provider_cache_read_cost
write_rate: Final = cache_creation_cost or prompt_base_cost
write_rate_1h: Final = cache_creation_cost_above_1hr or write_rate
prompt_tokens_details: Final = parse_prompt_tokens_details(usage)
@ -1786,7 +1914,7 @@ def calculate_prompt_caching_savings(
if details is not None
else None
)
read_discount: Final = cache_read_tokens * max(prompt_base_cost - cache_read_cost, 0.0)
read_discount: Final = cache_read_tokens * max(prompt_base_cost - effective_cache_read_cost, 0.0)
write_premium: Final = calculate_cache_writing_cost(
cache_creation_tokens=cache_creation_tokens,
cache_creation_token_details=cache_creation_details,

View file

@ -24,7 +24,9 @@ def _qwen3_5_tool_schemas(functions: Sequence[Mapping[str, object]]) -> str:
_TOOL_SECTIONS: Final = (
(
re.compile(r"deepseek-v4(?![.\d]|p\d)", re.IGNORECASE),
re.compile(
r"deepseek-v4(?![.\d]|p\d)|^(?:deepseek/)?deepseek-(?:chat|reasoner|flash)(?::free)?$", re.IGNORECASE
),
ChatTemplateToolSection(222, 0, _deepseek_v4_tool_schemas),
),
(re.compile(r"qwen3[._]?5(?!\d)", re.IGNORECASE), ChatTemplateToolSection(196, 5, _qwen3_5_tool_schemas)),

View file

@ -624,7 +624,7 @@ def update_messages_with_model_file_ids(
def update_responses_input_with_model_file_ids(
input: object,
model_id: str | None = None,
model_file_id_mapping: dict[str, dict[str, str]] | None = None,
model_file_id_mapping: Mapping[str, Mapping[str, str]] | None = None,
) -> object:
"""
Updates responses API input with provider-specific file IDs.
@ -665,7 +665,7 @@ def update_responses_input_with_model_file_ids(
for content_item in content:
if isinstance(content_item, dict) and content_item.get("type") == "input_file":
file_id = content_item.get("file_id")
if file_id:
if isinstance(file_id, str) and file_id:
provider_file_id = file_id # Default to original
# Check if we have a mapping for this file ID
@ -761,7 +761,7 @@ def _decode_vector_store_ids_in_tools(
def update_responses_tools_with_model_file_ids(
tools: list[dict[str, object]] | None,
model_id: str | None = None,
model_file_id_mapping: dict[str, dict[str, str]] | None = None,
model_file_id_mapping: Mapping[str, Mapping[str, str]] | None = None,
) -> list[dict[str, object]] | None:
"""
Updates responses API tools with provider-specific file IDs.
@ -2325,6 +2325,16 @@ def is_unsignable_thinking_block(block: object) -> bool:
return not (isinstance(signature, str) and len(signature) > 0)
def has_blank_redacted_data(block: object) -> bool:
match block:
case {"type": "redacted_thinking", "data": str() as data}:
return not data.strip()
case {"type": "redacted_thinking"}:
return True
case _:
return False
def native_redacted_thinking_data(data: str) -> str:
"""Claude's own `data` string for a redacted_thinking block.

View file

@ -6,10 +6,10 @@ import json
import mimetypes
import re
import xml.etree.ElementTree as ET
from collections.abc import Container, Iterable, Iterator, Mapping, Sequence
from collections.abc import Callable, Container, Iterable, Iterator, Mapping, Sequence
from enum import Enum
from types import MappingProxyType
from typing import Annotated, Any, Final, TypeAlias, TypedDict, cast, overload
from typing import Annotated, Any, Final, TypeAlias, TypedDict, TypeVar, cast, overload
from jinja2.sandbox import ImmutableSandboxedEnvironment
from pydantic import BaseModel, BeforeValidator, ConfigDict, JsonValue, TypeAdapter, ValidationError, model_validator
@ -61,6 +61,7 @@ from litellm.types.utils import GenericImageParsingChunk
from .common_utils import (
converse_redacted_content,
convert_content_list_to_str,
has_blank_redacted_data,
infer_content_type_from_url_and_content,
is_encrypted_reasoning_block,
is_non_content_values_set,
@ -1669,7 +1670,7 @@ def _replace_invalid_tool_use_id_chars(tool_use_id: str, invalid_chars: re.Patte
return invalid_chars.sub("_", tool_use_id) or _TOOL_USE_ID_FALLBACK
def _sanitize_anthropic_tool_use_id(tool_use_id: str) -> str:
def sanitize_anthropic_tool_use_id(tool_use_id: str) -> str:
"""Anthropic requires tool_use_id to match ^[a-zA-Z0-9_-]+$."""
return _replace_invalid_tool_use_id_chars(tool_use_id, _ANTHROPIC_TOOL_USE_ID_INVALID_CHARS)
@ -1815,7 +1816,7 @@ def convert_to_anthropic_tool_result(
tool_message: Final[ChatCompletionToolMessage] = message
tool_call_id: str = tool_message["tool_call_id"]
# Sanitize tool_use_id to match Anthropic's pattern requirement: ^[a-zA-Z0-9_-]+$
sanitized_tool_use_id = _sanitize_anthropic_tool_use_id(tool_call_id)
sanitized_tool_use_id = sanitize_anthropic_tool_use_id(tool_call_id)
# We can't determine from openai message format whether it's a successful or
# error call result so default to the successful result template
@ -1829,7 +1830,7 @@ def convert_to_anthropic_tool_result(
function_message: Final[ChatCompletionFunctionMessage] = message
tool_call_id = function_message.get("tool_call_id") or str(uuid.uuid4())
# Sanitize tool_use_id to match Anthropic's pattern requirement: ^[a-zA-Z0-9_-]+$
sanitized_tool_use_id = _sanitize_anthropic_tool_use_id(tool_call_id)
sanitized_tool_use_id = sanitize_anthropic_tool_use_id(tool_call_id)
anthropic_tool_result = AnthropicMessagesToolResultParam(
type="tool_result",
tool_use_id=sanitized_tool_use_id,
@ -2037,7 +2038,7 @@ def convert_to_anthropic_tool_invoke(
elif is_anthropic_tool_search_use(tool_id, tool_name) and tool_id not in answered_tool_ids:
continue
else:
sanitized_tool_id = _sanitize_anthropic_tool_use_id(tool_id)
sanitized_tool_id = sanitize_anthropic_tool_use_id(tool_id)
_anthropic_tool_use_param = AnthropicMessagesToolUseParam(
type="tool_use",
id=sanitized_tool_id,
@ -2550,22 +2551,125 @@ def _pair_tool_calls_with_results(messages: Sequence[AllMessageValues]) -> tuple
def _replayable_thinking_blocks(
thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock],
*,
drop_params: bool = False,
) -> list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock]:
keys_to_drop: Final = _UNSUPPORTED_THINKING_KEYS if drop_params else _STREAM_ONLY_THINKING_KEYS
return [
_without_stream_index(_with_native_redacted_data(block))
_without_keys(_with_native_redacted_data(block), keys_to_drop)
for block in thinking_blocks
if not is_unsignable_thinking_block(block)
if not (is_unsignable_thinking_block(block) or (drop_params and has_blank_redacted_data(block)))
]
def _without_stream_index(
def _as_thinking_block(item: object) -> ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock | None:
match item:
case {"type": "thinking" | "redacted_thinking"}:
return cast( # cast-ok: the mapping pattern matched a thinking block's type tag
ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock, item
)
case _:
return None
def _thinking_identity(item: object) -> tuple[str | None, ...] | None:
match item:
case {"type": "redacted_thinking", "data": str() as data}:
return ("redacted_thinking", native_redacted_thinking_data(data))
case {"type": "thinking", "signature": str() as signature} if signature:
return ("thinking", signature)
case {"type": str() as kind} if kind in ("thinking", "redacted_thinking"):
fields: Final = cast(Mapping[str, object], item) # cast-ok: the mapping pattern matched
return _unsigned_thinking_identity(kind, fields.get("thinking"), fields.get("signature"))
case _:
return None
def _unsigned_thinking_identity(kind: str, thinking: object, signature: object) -> tuple[str | None, ...] | None:
match thinking, signature:
case (str() | None as text, str() | None as signed):
return (kind, text, signed)
case _:
return None
_Replayed: Final = TypeVar("_Replayed")
def _with_missing_thinking_blocks(
content: Iterable[_Replayed], thinking_blocks: Sequence[_Replayed]
) -> tuple[_Replayed, ...]:
content_items: Final = tuple(content)
block_identities: Final = tuple(map(_thinking_identity, thinking_blocks))
content_positions: Final[Mapping[tuple[object, ...] | None, int]] = MappingProxyType(
{
identity: index
for index, identity in reversed(tuple(enumerate(map(_thinking_identity, content_items))))
if identity is not None
}
)
anchors: Final = tuple(index for index, identity in enumerate(block_identities) if identity in content_positions)
if not anchors:
return (*thinking_blocks, *content_items)
missing_before: Final = MappingProxyType(
{
content_positions[block_identities[end]]: thinking_blocks[start + 1 : end]
for start, end in zip((-1, *anchors), anchors)
}
)
missing_after: Final = MappingProxyType(
{content_positions[block_identities[anchors[-1]]]: thinking_blocks[anchors[-1] + 1 :]}
)
return tuple(_interleave(content_items, missing_before, missing_after))
def _without_repeated_thinking(
items: Sequence[_Replayed], identity: Callable[[_Replayed], tuple[object, ...] | None]
) -> tuple[_Replayed, ...]:
identities: Final = tuple(map(identity, items))
first_positions: Final[Mapping[tuple[object, ...], int]] = MappingProxyType(
{key: index for index, key in reversed(tuple(enumerate(identities))) if key is not None}
)
return tuple(
item for index, (item, key) in enumerate(zip(items, identities)) if key is None or first_positions[key] == index
)
def _interleave(
content: Sequence[_Replayed],
before: Mapping[int, Sequence[_Replayed]],
after: Mapping[int, Sequence[_Replayed]],
) -> Iterator[_Replayed]:
for index, item in enumerate(content):
yield from before.get(index, ())
yield item
yield from after.get(index, ())
def _thinking_in_replay_order(
content: str | Iterable[object] | None, thinking_blocks: Sequence[object] | None
) -> tuple[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock, ...]:
content_items: Final = content if isinstance(content, list) else ()
return tuple(
block
for item in _with_missing_thinking_blocks(content_items, thinking_blocks or ())
if (block := _as_thinking_block(item)) is not None
)
_STREAM_ONLY_THINKING_KEYS: Final = frozenset({"index"})
_UNSUPPORTED_THINKING_KEYS: Final = frozenset({"index", "cache_control"})
def _without_keys(
block: ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock,
keys: frozenset[str],
) -> ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock:
if "index" not in block:
if keys.isdisjoint(block):
return block
return cast( # cast-ok: dropping one key keeps the block's TypedDict shape
return cast( # cast-ok: dropping keys keeps the block's TypedDict shape
ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock,
{key: value for key, value in block.items() if key != "index"},
{key: value for key, value in block.items() if key not in keys},
)
@ -2629,6 +2733,7 @@ def anthropic_messages_pt(
messages: list[AllMessageValues],
model: str,
llm_provider: str,
drop_params: bool = False,
) -> _AnthropicMessageList:
"""
format messages for anthropic
@ -2816,7 +2921,9 @@ def anthropic_messages_pt(
_raw_thinking_blocks = assistant_content_block.get("thinking_blocks", None)
thinking_blocks = (
_replayable_thinking_blocks(_raw_thinking_blocks) if _raw_thinking_blocks is not None else None
_replayable_thinking_blocks(_raw_thinking_blocks, drop_params=drop_params)
if _raw_thinking_blocks is not None
else None
)
# Check if tool_calls contain server tool calls (web search, etc.)
@ -2951,42 +3058,28 @@ def anthropic_messages_pt(
# SEQUENTIAL MODE: No server tool calls, or no thinking blocks,
# or content is a list. Use the original sequential approach.
# When content is a list, check if it already contains thinking
# blocks inline. If so, skip prepending thinking_blocks to avoid
# duplication and preserve the original interleaved order.
# Fixes the gap where list-content messages bypass INTERLEAVED
# MODE and still get thinking blocks prepended out of order.
_content_is_list = "content" in assistant_content_block and isinstance(
assistant_content_block["content"], list
)
_content_list = assistant_content_block.get("content") if _content_is_list else None
_list_has_thinking = False
if _content_is_list and _content_list is not None:
for _item in _content_list:
if isinstance(_item, dict) and _item.get("type") in (
"thinking",
"redacted_thinking",
):
_list_has_thinking = True
break
if (
thinking_blocks is not None and not _list_has_thinking
thinking_blocks is not None and not _content_is_list
): # IMPORTANT: ADD THIS FIRST, ELSE ANTHROPIC WILL RAISE AN ERROR
assistant_content.extend(thinking_blocks)
if _content_is_list and _content_list is not None:
for m in _content_list:
for m in _with_missing_thinking_blocks(_content_list, thinking_blocks or ()):
if not isinstance(m, dict):
continue
# handle thinking blocks
text_block = cast(str, m.get("text", ""))
if m.get("type", "") == "thinking" and not is_unsignable_thinking_block(m):
anthropic_message: ChatCompletionThinkingBlock | AnthropicMessagesTextParam = cast(
ChatCompletionThinkingBlock, m
if (thinking_block := _as_thinking_block(m)) is not None:
assistant_content.extend(
_replayable_thinking_blocks([thinking_block], drop_params=drop_params)
)
assistant_content.append(anthropic_message)
continue
text_block = cast(str, m.get("text", ""))
# handle text
elif (
if (
m.get("type", "") == "text" and len(text_block) > 0
): # don't pass empty text blocks. anthropic api raises errors.
anthropic_message = AnthropicMessagesTextParam(type="text", text=text_block)
@ -3058,7 +3151,12 @@ def anthropic_messages_pt(
msg_i += 1
if assistant_content:
new_messages.append({"role": "assistant", "content": assistant_content})
new_messages.append(
{
"role": "assistant",
"content": list(_without_repeated_thinking(assistant_content, _thinking_identity)),
}
)
## MID-CONVERSATION SYSTEM MESSAGES (placement is the caller's job) ##
while msg_i < len(messages) and messages[msg_i]["role"] == "system":
@ -4190,6 +4288,16 @@ def _convert_to_bedrock_tool_call_result(
return content_block
def _reasoning_identity(block: BedrockContentBlock) -> tuple[object, ...] | None:
match block:
case {"reasoningContent": {"redactedContent": redacted}}:
return ("redacted_thinking", redacted)
case {"reasoningContent": {"reasoningText": {"signature": str() as signature}}} if signature:
return ("thinking", signature)
case _:
return None
def _deduplicate_bedrock_content_blocks(
blocks: list[BedrockContentBlock],
block_key: str,
@ -4673,6 +4781,7 @@ class BedrockConverseMessagesProcessor:
assistant_continue_message: str | ChatCompletionAssistantMessage | None = None,
deployment_model: str | None = None,
base_model: str | None = None,
drop_params: bool = False,
) -> list[BedrockMessageBlock]:
from litellm.llms.bedrock.common_utils import bedrock_converse_accepts_redacted_thinking
@ -4826,33 +4935,21 @@ class BedrockConverseMessagesProcessor:
)
_assistant_content = assistant_message_block.get("content", None)
thinking_blocks = assistant_message_block.get("thinking_blocks")
if thinking_blocks is not None:
converted_thinking_blocks = (
BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks(
thinking_blocks, to_claude=to_claude
)
)
assistant_content = BedrockConverseMessagesProcessor.add_thinking_blocks_to_assistant_content(
thinking_blocks=converted_thinking_blocks,
assistant_parts=assistant_content,
converted_thinking_blocks = (
BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks(
_thinking_in_replay_order(_assistant_content, thinking_blocks), to_claude=to_claude
)
)
assistant_content = BedrockConverseMessagesProcessor.add_thinking_blocks_to_assistant_content(
thinking_blocks=converted_thinking_blocks,
assistant_parts=assistant_content,
)
if _assistant_content is not None and isinstance(_assistant_content, list):
assistants_parts: list[BedrockContentBlock] = []
for element in _assistant_content:
if isinstance(element, dict):
if element["type"] == "thinking" or element["type"] == "redacted_thinking":
thinking_block = BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks(
thinking_blocks=[element], to_claude=to_claude
)
assistants_parts = (
BedrockConverseMessagesProcessor.add_thinking_blocks_to_assistant_content(
thinking_blocks=thinking_block,
assistant_parts=assistants_parts,
)
)
elif element["type"] == "text":
if element["type"] == "text":
# Skip completely empty strings to avoid blank content blocks
if element.get("text", "").strip():
assistants_part = BedrockContentBlock(text=element["text"])
@ -4871,7 +4968,9 @@ class BedrockConverseMessagesProcessor:
model=model,
deployment_model=deployment_model,
)
if _cache_point_block is not None:
if _cache_point_block is not None and not (
drop_params and _as_thinking_block(element) is not None
):
assistants_parts.append(_cache_point_block)
assistant_content.extend(assistants_parts)
elif _assistant_content is not None and isinstance(_assistant_content, str):
@ -4899,7 +4998,11 @@ class BedrockConverseMessagesProcessor:
msg_i += 1
assistant_content = _deduplicate_bedrock_content_blocks(assistant_content, "toolUse")
assistant_content = list(
_without_repeated_thinking(
_deduplicate_bedrock_content_blocks(assistant_content, "toolUse"), _reasoning_identity
)
)
assistant_content = _sort_bedrock_assistant_content_blocks(assistant_content)
if assistant_content:
@ -5083,6 +5186,7 @@ def _bedrock_converse_messages_pt(
assistant_continue_message: str | ChatCompletionAssistantMessage | None = None,
deployment_model: str | None = None,
base_model: str | None = None,
drop_params: bool = False,
) -> list[BedrockMessageBlock]:
"""
Converts given messages from OpenAI format to Bedrock format
@ -5244,35 +5348,21 @@ def _bedrock_converse_messages_pt(
)
_assistant_content = assistant_message_block.get("content", None)
thinking_blocks = assistant_message_block.get("thinking_blocks")
if thinking_blocks is not None:
converted_thinking_blocks = (
BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks(
thinking_blocks, to_claude=to_claude
)
)
assistant_content = BedrockConverseMessagesProcessor.add_thinking_blocks_to_assistant_content(
thinking_blocks=converted_thinking_blocks,
assistant_parts=assistant_content,
converted_thinking_blocks = (
BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks(
_thinking_in_replay_order(_assistant_content, thinking_blocks), to_claude=to_claude
)
)
assistant_content = BedrockConverseMessagesProcessor.add_thinking_blocks_to_assistant_content(
thinking_blocks=converted_thinking_blocks,
assistant_parts=assistant_content,
)
if _assistant_content is not None and isinstance(_assistant_content, list):
assistants_parts: list[BedrockContentBlock] = []
for element in _assistant_content:
if isinstance(element, dict):
if element["type"] == "thinking" or element["type"] == "redacted_thinking":
thinking_block = (
BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks(
thinking_blocks=[element], to_claude=to_claude
)
)
assistants_parts = (
BedrockConverseMessagesProcessor.add_thinking_blocks_to_assistant_content(
thinking_blocks=thinking_block,
assistant_parts=assistants_parts,
)
)
elif element["type"] == "text":
if element["type"] == "text":
# AWS Bedrock doesn't allow empty or whitespace-only text content
# Skip completely empty strings to avoid blank content blocks
if element.get("text", "").strip():
@ -5292,7 +5382,9 @@ def _bedrock_converse_messages_pt(
model=model,
deployment_model=deployment_model,
)
if _cache_point_block is not None:
if _cache_point_block is not None and not (
drop_params and _as_thinking_block(element) is not None
):
assistants_parts.append(_cache_point_block)
assistant_content.extend(assistants_parts)
elif _assistant_content is not None and isinstance(_assistant_content, str):
@ -5313,7 +5405,11 @@ def _bedrock_converse_messages_pt(
msg_i += 1
assistant_content = _deduplicate_bedrock_content_blocks(assistant_content, "toolUse")
assistant_content = list(
_without_repeated_thinking(
_deduplicate_bedrock_content_blocks(assistant_content, "toolUse"), _reasoning_identity
)
)
assistant_content = _sort_bedrock_assistant_content_blocks(assistant_content)
if assistant_content:
@ -5900,21 +5996,21 @@ def _optional_tool_name(value: object) -> str | None:
def _split_tool_call_ids(calls: Sequence[tuple[str | None, int]]) -> tuple[tuple[str | None, ...], ...]:
taken: Final = frozenset(_sanitize_anthropic_tool_use_id(call_id) for call_id, _ in calls if call_id)
taken: Final = frozenset(sanitize_anthropic_tool_use_id(call_id) for call_id, _ in calls if call_id)
def fresh(call_id: str) -> Iterator[str]:
return filter(
lambda candidate: _sanitize_anthropic_tool_use_id(candidate) not in taken,
lambda candidate: sanitize_anthropic_tool_use_id(candidate) not in taken,
(f"{call_id}__concat_{n}" for n in itertools.count(1)),
)
suffixes: Final = MappingProxyType(
{_sanitize_anthropic_tool_use_id(call_id): fresh(call_id) for call_id, count in calls if call_id and count > 1}
{sanitize_anthropic_tool_use_id(call_id): fresh(call_id) for call_id, count in calls if call_id and count > 1}
)
return tuple(
(
call_id,
*(next(suffixes[_sanitize_anthropic_tool_use_id(call_id)]) for _ in range(count - 1)),
*(next(suffixes[sanitize_anthropic_tool_use_id(call_id)]) for _ in range(count - 1)),
)
if call_id
else (None,) * count

View file

@ -49,7 +49,7 @@ from litellm.types.llms.openai import (
ChatCompletionUserMessage,
)
from .common_utils import is_unsignable_thinking_block
from .common_utils import has_blank_redacted_data, is_unsignable_thinking_block
CONVERTED_SYSTEM_NOTE: Final = (
"Operator note (not from the user): the following was originally a mid-conversation system-role reminder."
@ -267,27 +267,22 @@ def _block_index_by_message(blocks: Sequence[tuple[bool, tuple[int, ...]]]) -> M
def _thinking_block_renders(block: object) -> bool:
"""A thinking block the converter keeps: one Anthropic can verify, so never bridged encrypted reasoning."""
return message_field(block, "type") in _THINKING_BLOCK_TYPES and not is_unsignable_thinking_block(block)
return message_field(block, "type") in _THINKING_BLOCK_TYPES and not (
is_unsignable_thinking_block(block) or has_blank_redacted_data(block)
)
def _assistant_part_renders(part: object) -> bool:
"""A text part always renders: the converter pads empty text with a placeholder."""
part_type: Final = message_field(part, "type")
if part_type == "thinking":
if part_type in _THINKING_BLOCK_TYPES:
return _thinking_block_renders(part)
return part_type in _RENDERED_ASSISTANT_PART_TYPES or (
isinstance(part_type, str) and part_type.endswith("_tool_result")
)
def _separate_thinking_blocks_render(message: object, parts: Sequence[object]) -> bool:
"""``thinking_blocks`` reach the wire only when no inline thinking part claims the slot.
The converter skips the separate blocks as soon as the content list carries a
``thinking`` or ``redacted_thinking`` part, whether or not that part itself renders.
"""
if any(message_field(part, "type") in _THINKING_BLOCK_TYPES for part in parts):
return False
def _separate_thinking_blocks_render(message: object) -> bool:
return any(_thinking_block_renders(block) for block in parts_of(message_field(message, "thinking_blocks")))
@ -404,7 +399,7 @@ def _assistant_renders(message: object, answered: frozenset[str]) -> bool:
parts: Final = parts_of(content)
return (
any(_assistant_part_renders(part) for part in parts)
or _separate_thinking_blocks_render(message, parts)
or _separate_thinking_blocks_render(message)
or _tool_calls_render(message, answered)
or bool(message_field(message, "function_call"))
or bool(message_field(message_field(message, "provider_specific_fields"), "compaction_blocks"))

View file

@ -1,16 +1,19 @@
from typing import Literal
from typing import Final, Literal
import litellm
from litellm.constants import (
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
)
from litellm.exceptions import UnsupportedParamsError
from litellm.litellm_core_utils.core_helpers import as_str_mapping
OpenAIStyleReasoningEffort = Literal["minimal", "low", "medium", "high"]
def reasoning_effort_from_thinking_budget(
budget_tokens: int,
budget_tokens: float,
) -> OpenAIStyleReasoningEffort:
"""Bucket an Anthropic ``thinking.budget_tokens`` into an OpenAI-style
``reasoning_effort`` using the shared ``DEFAULT_REASONING_EFFORT_*_THINKING_BUDGET``
@ -24,3 +27,24 @@ def reasoning_effort_from_thinking_budget(
if budget_tokens >= DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET:
return "low"
return "minimal"
def reasoning_effort_from_thinking(thinking: object, *, drop_params: bool) -> OpenAIStyleReasoningEffort | None:
fields: Final = as_str_mapping(thinking)
if fields is None or fields.get("type") != "enabled":
return None
budget: Final = fields.get("budget_tokens", 0)
if isinstance(budget, (int, float)) and not isinstance(budget, bool):
return reasoning_effort_from_thinking_budget(budget)
if drop_params or litellm.drop_params is True:
return None
raise non_numeric_thinking_budget_error(budget)
def non_numeric_thinking_budget_error(budget: object) -> UnsupportedParamsError:
return UnsupportedParamsError(
message=(
f"thinking.budget_tokens must be a number, got {type(budget).__name__}. To drop it, set "
"`litellm.drop_params=True`, or `drop_params: true` under `litellm_settings` on the proxy"
)
)

View file

@ -10,10 +10,12 @@
import asyncio
import copy
import inspect
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from dataclasses import replace
from typing import TYPE_CHECKING, Any, Final
from pydantic import TypeAdapter
import litellm
from litellm.constants import REDACTED_BY_LITELLM
from litellm.integrations.custom_logger import CustomLogger
@ -40,36 +42,79 @@ else:
LiteLLMLoggingObject = Any
def _opted_out_of_message_logging(custom_logger: CustomLogger) -> bool:
return hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True
def redact_message_input_output_from_custom_logger(
litellm_logging_obj: LiteLLMLoggingObject, result, custom_logger: CustomLogger
):
if hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True:
) -> object:
if _opted_out_of_message_logging(custom_logger):
return perform_redaction(litellm_logging_obj.model_call_details, result, redact_streaming_responses=False)
return result
def redact_streaming_responses_for_custom_logger(model_call_details: dict, custom_logger: CustomLogger) -> dict:
def redacted_model_call_details_for_custom_logger(model_call_details: dict, custom_logger: CustomLogger) -> dict:
"""
Returns a copy of model_call_details whose streaming response entries are redacted deepcopies
when the custom logger has opted out of message logging. The shared model_call_details is left
untouched so other callbacks still receive the unredacted response.
Returns a copy of model_call_details with the request messages, instructions and streaming responses
redacted when the custom logger has opted out of message logging. The shared model_call_details is left
untouched so other callbacks and the spend log still receive the unredacted values.
"""
if not (hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True):
if not _opted_out_of_message_logging(custom_logger):
return model_call_details
redacted_entries: Final = {
streaming_key: _redacted_streaming_response_copy(model_call_details[streaming_key])
for streaming_key in ("complete_streaming_response", "async_complete_streaming_response")
if model_call_details.get(streaming_key) is not None
return {
**{key: value for key, value in model_call_details.items() if key != SERVED_OUTPUT_TEXTS_KEY},
**_redacted_entries_for_opted_out_logger(model_call_details),
}
if not redacted_entries:
return model_call_details
return {**model_call_details, **redacted_entries}
def _redacted_streaming_response_copy(streaming_response):
redacted_response: Final = copy.deepcopy(streaming_response)
_redact_streaming_response(redacted_response)
return redacted_response
def _redacted_entries_for_opted_out_logger(model_call_details: Mapping[str, object]) -> Mapping[str, object]:
optional_params: Final = _as_mapping(model_call_details.get("optional_params"))
litellm_params: Final = _as_mapping(model_call_details.get("litellm_params"))
standard_logging_object: Final = _as_mapping(model_call_details.get("standard_logging_object"))
return {
"messages": [{"role": "user", "content": REDACTED_BY_LITELLM}],
"prompt": "",
"input": "",
**({"instructions": REDACTED_BY_LITELLM} if model_call_details.get("instructions") is not None else {}),
**({"optional_params": _with_redacted_instructions(optional_params)} if optional_params is not None else {}),
**({"litellm_params": redacted_litellm_params(litellm_params)} if litellm_params is not None else {}),
**(
{"standard_logging_object": _redact_standard_logging_object(standard_logging_object)}
if standard_logging_object is not None
else {}
),
**{
streaming_key: _redacted_streaming_response_copy(model_call_details[streaming_key])
for streaming_key in (
"complete_streaming_response",
"async_complete_streaming_response",
"complete_response",
)
if model_call_details.get(streaming_key) is not None
},
}
def redacted_response_obj_for_custom_logger(response_obj: object, custom_logger: CustomLogger) -> object:
if not _opted_out_of_message_logging(custom_logger) or not _is_redactable_response(response_obj):
return response_obj
return perform_redaction({}, response_obj)
def _is_redactable_response(response_obj: object) -> bool:
return isinstance(
response_obj, (litellm.ModelResponse, litellm.ResponsesAPIResponse, litellm.EmbeddingResponse)
) or (isinstance(response_obj, dict) and ("choices" in response_obj or "output" in response_obj))
def _redacted_streaming_response_copy(streaming_response: object) -> object:
redacted_response: Final = (
streaming_response
if isinstance(streaming_response, litellm.ResponsesAPIResponse)
else copy.deepcopy(streaming_response)
)
return _redact_streaming_response(redacted_response)
def _redact_streaming_response(streaming_response):
@ -77,10 +122,13 @@ def _redact_streaming_response(streaming_response):
for choice in streaming_response.choices:
_redact_choice_content(choice)
redact_vertex_ai_metadata_from_logged_object(streaming_response)
elif isinstance(streaming_response, litellm.ResponsesAPIResponse):
return perform_redaction({}, streaming_response)
elif hasattr(streaming_response, "output"):
_redact_responses_api_output(streaming_response.output)
if hasattr(streaming_response, "reasoning") and streaming_response.reasoning is not None:
streaming_response.reasoning = None
return streaming_response
def _redact_tool_calls(tool_calls) -> None:
@ -177,31 +225,75 @@ def _redact_responses_api_output(output_items):
output_item.input = REDACTED_BY_LITELLM
def _redact_responses_api_output_dict(output_items, redacted_str: str):
"""Helper to redact ResponsesAPIResponse output items in dict form."""
for output_item in output_items:
if not isinstance(output_item, dict):
continue
_RESPONSE_ITEMS: Final = TypeAdapter(tuple[object, ...])
if output_item.get("text") is not None:
output_item["text"] = redacted_str
if isinstance(output_item.get("content"), list):
for content_item in output_item["content"]:
if isinstance(content_item, dict) and content_item.get("text") is not None:
content_item["text"] = redacted_str
if isinstance(content_item, dict) and content_item.get("refusal") is not None:
content_item["refusal"] = redacted_str
def _redacted_responses_api_output_part(part: object, fields: tuple[str, ...], redacted_str: str) -> object:
if (part_mapping := _as_mapping(part)) is None:
return part
return {key: redacted_str if key in fields and value is not None else value for key, value in part_mapping.items()}
if output_item.get("type") == "reasoning" and isinstance(output_item.get("summary"), list):
for summary_item in output_item["summary"]:
if isinstance(summary_item, dict) and summary_item.get("text") is not None:
summary_item["text"] = redacted_str
if output_item.get("type") == "function_call" and "arguments" in output_item:
output_item["arguments"] = redacted_str
if output_item.get("type") == "custom_tool_call" and "input" in output_item:
output_item["input"] = redacted_str
def _redacted_responses_api_output_item(item: object, redacted_str: str) -> object:
if (item_mapping := _as_mapping(item)) is None:
return item
content: Final = item_mapping.get("content")
summary: Final = item_mapping.get("summary")
return {
**item_mapping,
**({"text": redacted_str} if item_mapping.get("text") is not None else {}),
**(
{
"content": [
_redacted_responses_api_output_part(part, ("text", "refusal"), redacted_str)
for part in _RESPONSE_ITEMS.validate_python(content)
]
}
if isinstance(content, list)
else {}
),
**(
{
"summary": [
_redacted_responses_api_output_part(part, ("text",), redacted_str)
for part in _RESPONSE_ITEMS.validate_python(summary)
]
}
if item_mapping.get("type") == "reasoning" and isinstance(summary, list)
else {}
),
**(
{"arguments": redacted_str}
if item_mapping.get("type") == "function_call" and "arguments" in item_mapping
else {}
),
**(
{"input": redacted_str}
if item_mapping.get("type") == "custom_tool_call" and "input" in item_mapping
else {}
),
}
def _redact_responses_api_output_dict(output_items: Sequence[object], redacted_str: str) -> Sequence[object]:
return [_redacted_responses_api_output_item(item, redacted_str) for item in output_items]
def _with_redacted_instructions(params: Mapping[str, object]) -> Mapping[str, object]:
return params if params.get("instructions") is None else {**params, "instructions": REDACTED_BY_LITELLM}
def _redacted_responses_api_response(
response: Mapping[str, object], *, redact_instructions: bool = True
) -> Mapping[str, object]:
output: Final = response.get("output")
redacted: Final = {
**response,
"output": _redact_responses_api_output_dict(_RESPONSE_ITEMS.validate_python(output), REDACTED_BY_LITELLM)
if isinstance(output, list)
else output,
}
return _with_redacted_instructions(redacted) if redact_instructions else redacted
def redacted_standard_logging_payload(payload: Mapping[str, object]) -> Mapping[str, object]:
@ -215,20 +307,25 @@ def redacted_standard_logging_payload(payload: Mapping[str, object]) -> Mapping[
return _redact_standard_logging_object(payload)
def _redact_standard_logging_object(payload: Mapping[str, object]) -> dict[str, object]:
def _redact_standard_logging_object(
payload: Mapping[str, object], *, redact_instructions: bool = True
) -> dict[str, object]:
standard_logging_object: Final = copy.deepcopy(without_classifier_audit(payload))
redacted_str: Final = REDACTED_BY_LITELLM
if standard_logging_object.get("messages") is not None:
standard_logging_object["messages"] = [{"role": "user", "content": redacted_str}]
model_parameters: Final = _as_mapping(standard_logging_object.get("model_parameters"))
if model_parameters is not None and redact_instructions:
standard_logging_object["model_parameters"] = _with_redacted_instructions(model_parameters)
response: Final = standard_logging_object.get("response")
if response is not None:
if isinstance(response, dict) and "output" in response:
# ResponsesAPIResponse format - redact content in output items
if isinstance(response.get("output"), list):
_redact_responses_api_output_dict(response["output"], redacted_str)
redact_vertex_ai_metadata_from_logged_object(response)
standard_logging_object["response"] = _redacted_responses_api_response(
response, redact_instructions=redact_instructions
)
redact_vertex_ai_metadata_from_logged_object(standard_logging_object["response"])
elif isinstance(response, dict) and "choices" in response:
# ModelResponse dict format - redact content in choices
if isinstance(response.get("choices"), list):
@ -320,11 +417,11 @@ def _redact_hidden_provider_specific_fields(params: Mapping[str, object]) -> Non
def redacted_litellm_params(params: Mapping[str, object], *, redact_request_body: bool = True) -> dict[str, object]:
"""
Copy of litellm_params without classifier audit fields or baseline route request parameters.
redact_request_body also redacts the nested proxy_server_request body.
redact_request_body also redacts the request instructions and the nested proxy_server_request body.
"""
request: Final = _as_mapping(params.get("proxy_server_request"))
return {
**params,
**(_with_redacted_instructions(params) if redact_request_body else params),
**{
key: _redacted_baseline_metadata(value)
for key, value in params.items()
@ -344,8 +441,9 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
redact_streaming_responses=False skips the in-place redaction of the shared streaming
response entries; per-callback redaction hands each opted-out callback its own redacted
copy via redact_streaming_responses_for_custom_logger instead. It also leaves the shared
proxy request body and hidden provider fields as they are, since the spend log reads them.
copy via redacted_model_call_details_for_custom_logger instead. It also leaves the shared
request instructions, proxy request body and hidden provider fields as they are, since the
spend log and the other callbacks read them.
"""
# Redact model_call_details
params: Final = _as_mapping(model_call_details.get("litellm_params"))
@ -358,16 +456,31 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
model_call_details["messages"] = [{"role": "user", "content": REDACTED_BY_LITELLM}]
model_call_details["prompt"] = ""
model_call_details["input"] = ""
optional_params: Final = _as_mapping(model_call_details.get("optional_params"))
if redact_streaming_responses:
model_call_details.update(
{
**({"instructions": REDACTED_BY_LITELLM} if model_call_details.get("instructions") is not None else {}),
**({"optional_params": _with_redacted_instructions(optional_params)} if optional_params else {}),
}
)
model_call_details.pop(SERVED_OUTPUT_TEXTS_KEY, None)
standard_logging_object: Final = model_call_details.get("standard_logging_object")
if isinstance(standard_logging_object, Mapping):
model_call_details["standard_logging_object"] = _redact_standard_logging_object(standard_logging_object)
model_call_details["standard_logging_object"] = _redact_standard_logging_object(
standard_logging_object, redact_instructions=redact_streaming_responses
)
redact_vertex_ai_metadata_from_litellm_params(model_call_details)
# Redact streaming response
if redact_streaming_responses and model_call_details.get("stream", False) is True:
for _streaming_key in ("complete_streaming_response", "async_complete_streaming_response"):
_redact_streaming_response(model_call_details.get(_streaming_key))
model_call_details.update(
{
streaming_key: _redact_streaming_response(model_call_details[streaming_key])
for streaming_key in ("complete_streaming_response", "async_complete_streaming_response")
if model_call_details.get(streaming_key) is not None
}
)
# Redact result
if result is not None:
@ -381,10 +494,7 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
# For async objects, return a simple redacted response without deepcopy
return {"text": REDACTED_BY_LITELLM}
if not (
isinstance(result, (litellm.ModelResponse, litellm.ResponsesAPIResponse, litellm.EmbeddingResponse))
or (isinstance(result, dict) and ("choices" in result or "output" in result))
):
if not _is_redactable_response(result):
return {"text": REDACTED_BY_LITELLM}
_result: Final = copy.deepcopy(result)
@ -399,14 +509,18 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
_redact_model_response_dict_choices(_result["choices"], REDACTED_BY_LITELLM)
redact_vertex_ai_metadata_from_logged_object(_result)
elif isinstance(_result, dict) and "output" in _result:
if isinstance(_result.get("output"), list):
_redact_responses_api_output_dict(_result["output"], REDACTED_BY_LITELLM)
return _redacted_responses_api_response(_result, redact_instructions=redact_streaming_responses)
elif isinstance(_result, litellm.ResponsesAPIResponse):
if hasattr(_result, "output"):
_redact_responses_api_output(_result.output)
# Redact reasoning field in ResponsesAPIResponse
if hasattr(_result, "reasoning") and _result.reasoning is not None:
_result.reasoning = None
return _result.model_copy(
update={"instructions": REDACTED_BY_LITELLM}
if redact_streaming_responses and _result.instructions is not None
else {}
)
elif isinstance(_result, litellm.EmbeddingResponse):
if hasattr(_result, "data") and _result.data is not None:
_result.data = []

View file

@ -12,10 +12,15 @@ tell "user asked for this" from "nobody set it". This resolver answers that:
from __future__ import annotations
from typing import Final
from collections.abc import Mapping
from typing import Final, Literal, Protocol
from pydantic import TypeAdapter
from litellm.constants import DEFAULT_REQUEST_TIMEOUT_SECONDS
_TIMEOUTS_BY_SERVICE_TIER: Final[TypeAdapter[Mapping[str, float] | None]] = TypeAdapter(Mapping[str, float] | None)
def get_configured_request_timeout() -> float | None:
"""Return the explicitly-configured ``litellm.request_timeout``, else ``None``."""
@ -27,3 +32,19 @@ def get_configured_request_timeout() -> float | None:
if timeout != float(DEFAULT_REQUEST_TIMEOUT_SECONDS):
return timeout
return None
class _DeploymentParams(Protocol):
def get(self, key: str, /) -> object: ...
def get_service_tier_timeout(
service_tier: object,
litellm_params: _DeploymentParams,
key: Literal["timeout_by_service_tier", "stream_timeout_by_service_tier"],
) -> float | None:
"""Return the deployment's ``key`` timeout for the request's ``service_tier``, else ``None``."""
if not isinstance(service_tier, str):
return None
timeouts: Final = _TIMEOUTS_BY_SERVICE_TIER.validate_python(litellm_params.get(key))
return None if timeouts is None else timeouts.get(service_tier)

View file

@ -1,13 +1,13 @@
import base64
import builtins
import time
from collections.abc import Callable, Iterator, Mapping, Sequence
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass, replace
from itertools import chain, groupby
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Optional, TypeAlias, TypedDict, Union, cast
from typing import TYPE_CHECKING, Final, Optional, Protocol, TypeAlias, TypedDict, TypeVar, Union, cast
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, Required
import litellm
@ -17,6 +17,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_content_from_model_response, # pyright: ignore[reportUnknownVariableType] # response param accepts an untyped dict
)
from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionAssistantContentValue,
ChatCompletionAudioDelta,
)
@ -36,12 +37,16 @@ from litellm.types.utils import (
Function,
FunctionCall,
LlmProviders,
Logprobs,
Message,
ModelResponse,
ModelResponseStream,
PromptTokensDetailsWrapper,
ServerToolUse,
StreamingChoices,
TextChoices,
TextCompletionResponse,
UnreportedUsage,
Usage,
)
from litellm.utils import print_verbose, token_counter
@ -857,7 +862,9 @@ class ChunkProcessor:
return reasoning_tokens
@staticmethod
def _extract_usage_chunk(chunk: "_UsageBearingChunk | ModelResponse | ModelResponseStream") -> Usage | None:
def extract_usage_chunk(
chunk: "_UsageBearingChunk | ModelResponse | ModelResponseStream | TextCompletionResponse",
) -> Usage | None:
usage_chunk: Usage | CompletionUsage | None = None
if hasattr(chunk, "usage") and chunk.usage is not None:
usage_chunk = chunk.usage
@ -870,6 +877,8 @@ class ChunkProcessor:
if isinstance(usage_chunk, dict):
return Usage(**usage_chunk)
if isinstance(usage_chunk, UnreportedUsage):
return None
if usage_chunk is None or isinstance(usage_chunk, Usage):
return usage_chunk
return Usage(**usage_chunk.model_dump())
@ -916,7 +925,7 @@ class ChunkProcessor:
cost: float | None = None
for chunk in chunks:
usage_chunk = self._extract_usage_chunk(chunk)
usage_chunk = self.extract_usage_chunk(chunk)
if usage_chunk is not None:
usage_chunk_dict = self._usage_chunk_calculation_helper(usage_chunk)
@ -1013,7 +1022,7 @@ class ChunkProcessor:
values: Final = [
value
for chunk in chunks
if (usage_chunk := self._extract_usage_chunk(chunk)) is not None
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
@ -1086,7 +1095,7 @@ class ChunkProcessor:
chunks: Sequence["_UsageBearingChunk | ModelResponse"],
model: str,
completion_output: str,
messages: Sequence | None = None,
messages: Sequence[object] | None = None,
reasoning_tokens: int | None = None,
count_prompt_tokens: Callable[[], int] | None = None,
) -> Usage:
@ -1199,7 +1208,7 @@ class ChunkProcessor:
return returned_usage
def concatenate_base64_list(base64_strings: list[str]) -> str:
def concatenate_base64_list(base64_strings: Sequence[str]) -> str:
"""
Concatenates a list of base64-encoded strings.
@ -1216,35 +1225,115 @@ def concatenate_base64_list(base64_strings: list[str]) -> str:
return base64.b64encode(combined_bytes).decode("utf-8")
def stream_chunk_builder_text_completion(chunks: list, messages: Sequence | None = None) -> TextCompletionResponse:
id: Final = chunks[0]["id"]
object: Final = chunks[0]["object"]
created: Final = chunks[0]["created"]
model: Final = chunks[0]["model"]
system_fingerprint: Final = chunks[0].get("system_fingerprint", None)
finish_reason: Final = chunks[-1]["choices"][0]["finish_reason"]
logprobs: Final = chunks[-1]["choices"][0]["logprobs"]
_CHOICE_DICTS: Final = TypeAdapter(list[dict[str, object]])
content_list: Final = []
for chunk in chunks:
choices = chunk["choices"]
for choice in choices:
if choice is not None and hasattr(choice, "text") and choice.get("text") is not None:
_choice = choice.get("text")
content_list.append(_choice)
# Combine the "content" strings into a single string || combine the 'function' strings into a single string
combined_content: Final = "".join(content_list)
def _text_completion_chunk_from_mapping(chunk: Mapping[str, object]) -> TextCompletionResponse:
try:
prompt_tokens = token_counter(model=model, messages=messages)
except Exception: # don't allow this failing to block a complete streaming response from being returned
print_verbose("token_counter failed, assuming prompt tokens is 0")
prompt_tokens = 0
completion_tokens: Final = token_counter(
model=model,
text=combined_content,
count_response_tokens=True, # count_response_tokens is a Flag to tell token counter this is a response, No need to add extra tokens we do for input messages
return TextCompletionResponse.model_validate(chunk)
except ValidationError:
choices: Final = _CHOICE_DICTS.validate_python(chunk.get("choices", []))
return TextCompletionResponse.model_validate(
{**chunk, "choices": [{**choice, "logprobs": None} for choice in choices]}
)
def _as_text_completion_chunk(
chunk: TextCompletionResponse | ModelResponseStream | Mapping[str, object],
) -> TextCompletionResponse:
if isinstance(chunk, TextCompletionResponse):
return chunk
if isinstance(chunk, ModelResponseStream):
return TextCompletionResponse(
id=chunk.id,
created=chunk.created,
model=chunk.model,
choices=[TextChoices(index=choice.index, finish_reason=choice.finish_reason) for choice in chunk.choices],
usage=ChunkProcessor.extract_usage_chunk(chunk),
)
return _text_completion_chunk_from_mapping(chunk)
_LogprobsValue: Final = TypeVar("_LogprobsValue")
def _joined_or_none(fields: Iterable[Sequence[_LogprobsValue] | None]) -> list[_LogprobsValue] | None:
sent: Final = tuple(field for field in fields if field is not None)
return list(chain.from_iterable(sent)) if sent else None
class _CarriesLogprobs(Protocol):
@property
def logprobs(self) -> object: ...
def _sent_logprobs(choice: _CarriesLogprobs) -> object:
return choice.logprobs
def _merged_logprobs(choices: Sequence[TextChoices]) -> object:
sent: Final = tuple(logprobs for logprobs in map(_sent_logprobs, choices) if logprobs is not None)
parts: Final = tuple(logprobs for logprobs in sent if isinstance(logprobs, Logprobs))
if len(parts) < len(sent):
return sent[-1]
if not parts:
return None
return Logprobs(
text_offset=_joined_or_none(part.text_offset for part in parts),
token_logprobs=_joined_or_none(part.token_logprobs for part in parts),
tokens=_joined_or_none(part.tokens for part in parts),
top_logprobs=_joined_or_none(part.top_logprobs for part in parts),
)
def _text_of(choice: TextChoices) -> str:
return choice.text if isinstance(choice.text, str) else ""
def _index_of(choice: TextChoices) -> int:
try:
return int(choice.index or 0)
except (TypeError, ValueError, OverflowError):
return 0
def _rebuilt_text_choice(index: int, choices: Sequence[TextChoices]) -> TextChoices:
return TextChoices(
text="".join(map(_text_of, choices)),
index=index,
logprobs=_merged_logprobs(choices),
finish_reason=next((choice.finish_reason for choice in reversed(choices) if choice.finish_reason), None),
)
def _reported_usage(chunk: TextCompletionResponse) -> Usage | None:
usage: Final = ChunkProcessor.extract_usage_chunk(chunk)
return usage if usage is not None and (usage.prompt_tokens or usage.completion_tokens) else None
def stream_chunk_builder_text_completion(
chunks: Sequence[TextCompletionResponse | ModelResponseStream | Mapping[str, object]],
messages: Sequence[AllMessageValues | Message] | None = None,
) -> TextCompletionResponse:
text_chunks: Final = tuple(_as_text_completion_chunk(chunk) for chunk in chunks)
id: Final = text_chunks[0].id
object: Final = text_chunks[0].object
created: Final = text_chunks[0].created
model: Final = text_chunks[0].model
system_fingerprint: Final = text_chunks[0].get("system_fingerprint", None)
all_choices: Final = tuple(chain.from_iterable(chunk.choices for chunk in text_chunks))
choices: Final = [
_rebuilt_text_choice(index, tuple(group))
for index, group in groupby(sorted(all_choices, key=_index_of), key=_index_of)
] or [_rebuilt_text_choice(0, ())]
combined_content: Final = "".join(map(_text_of, choices))
reported_usages: Final = tuple(reported for reported in map(_reported_usage, text_chunks) if reported is not None)
usage: Final = ChunkProcessor(list(text_chunks), messages).calculate_usage(
chunks=tuple(_UsageBearingChunk(usage=reported) for reported in reported_usages),
model=model or "",
completion_output=combined_content,
messages=messages,
)
response: Final = {
@ -1253,19 +1342,8 @@ def stream_chunk_builder_text_completion(chunks: list, messages: Sequence | None
"created": created,
"model": model,
"system_fingerprint": system_fingerprint,
"choices": [
{
"text": combined_content,
"index": 0,
"logprobs": logprobs,
"finish_reason": finish_reason,
}
],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
"choices": choices,
"usage": usage,
}
return TextCompletionResponse(**response)
@ -1416,9 +1494,14 @@ def stream_chunk_builder(
return None
## Route to the text completion logic
first_chunk_with_choices: Final = next((c for c in chunks if c.get("choices")), None)
if first_chunk_with_choices is not None and isinstance(
first_chunk_with_choices["choices"][0], litellm.utils.TextChoices
): # route to the text completion logic
if first_chunk_with_choices is not None and (
isinstance(first_chunk_with_choices["choices"][0], TextChoices)
or (
isinstance(first_chunk_with_choices["choices"][0], Mapping)
and "text" in first_chunk_with_choices["choices"][0]
and "delta" not in first_chunk_with_choices["choices"][0]
)
):
return stream_chunk_builder_text_completion(chunks=chunks, messages=messages)
model: Final = chunks[0]["model"]

View file

@ -15,6 +15,8 @@ from typing import Any, Final, NoReturn, Protocol, TypeVar, cast
import anyio
import httpx
from openai.types.chat import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_chunk import ChoiceDelta
from pydantic import BaseModel, ValidationError
from typing_extensions import NotRequired, TypedDict
@ -22,7 +24,12 @@ import litellm
from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR, get_hidden_params
from litellm.litellm_core_utils.hidden_params import (
HIDDEN_PARAMS_ATTR,
get_hidden_params,
set_hidden_param,
stored_attribute,
)
from litellm.litellm_core_utils.model_response_utils import (
is_model_response_stream_empty,
)
@ -41,6 +48,8 @@ from litellm.types.utils import (
ModelResponseStream,
PromptTokensDetailsWrapper,
StreamingChoices,
TextCompletionResponse,
UnreportedUsage,
Usage,
)
from litellm.types.utils import GenericStreamingChunk as GChunk
@ -60,6 +69,7 @@ FUNCTION_CALL_ATTRIBUTE: Final = "function_call"
_SYNC_ITER_EXHAUSTED: Final = object()
_GCHUNK_FIELDS: Final[frozenset] = frozenset(GChunk.__annotations__)
_CompleteStreamingResponse = ModelResponse | TextCompletionResponse | ModelResponseStream | None
_USAGE_COST_HEADER_PROVIDERS: Final[frozenset[str]] = frozenset({LlmProviders.OPENROUTER.value})
@ -88,6 +98,30 @@ def _apply_provider_fields(response: ModelResponseStream, fields: Mapping[str, o
setattr(response, key, value)
def _prepend_upstream_choice_extras(
choice: StreamingChoices, upstream_choice: ChunkChoice, extra_keys: frozenset[str]
) -> None:
if not extra_keys:
return
upstream_extras: Final[Mapping[str, object]] = upstream_choice.model_dump(include=set(extra_keys))
choice.__pydantic_extra__ = {**upstream_extras, **(choice.__pydantic_extra__ or {})} # rebind-ok: reuse choice
choice.__pydantic_fields_set__.update(upstream_extras)
def _holds_only_scalars(model: BaseModel) -> bool:
fields: Final[Iterator[tuple[str, object]]] = iter(model)
return all(value is None or isinstance(value, (str, int, float, bool)) for _, value in fields)
def _delta_from_upstream(upstream_delta: BaseModel | None) -> Delta | None:
if upstream_delta is None:
return Delta()
try:
return Delta(**dict(iter(upstream_delta)))
except Exception:
return None
def _is_transport_timeout(error: BaseException) -> bool:
return isinstance(error, httpx.TimeoutException) or (
isinstance(error, Timeout) and isinstance(error.__cause__, httpx.TimeoutException)
@ -252,6 +286,7 @@ class CustomStreamWrapper:
self.completion_stream = completion_stream
self.sent_first_chunk = False
self.sent_last_chunk = False
self._openai_handler_receives_chat_chunks = False
self._failure_logging: asyncio.Task[None] | None = None
self._closed = False
self._stream_created_time: float = time.time()
@ -308,7 +343,8 @@ class CustomStreamWrapper:
self.sent_stream_usage = False
self.send_stream_usage = True if self.check_send_stream_usage(self.stream_options) else False
self.tool_call = False
self.chunks: list = [] # keep track of the returned chunks - used for calculating the input/output tokens for stream options
# keep track of the returned chunks - used for calculating the input/output tokens for stream options
self.chunks: list[ModelResponseStream] = []
self._repeated_messages_count = 1
self.is_function_call = self.check_is_function_call(logging_obj=logging_obj)
self.created: int | None = None
@ -517,7 +553,7 @@ class CustomStreamWrapper:
last_content: Final = self.chunks[-1].choices[0].delta.content
if (
last_content is None or not isinstance(last_content, str) or len(last_content) <= 2
not isinstance(last_content, str) or len(last_content) <= 2
): # ignore empty content - https://github.com/BerriAI/litellm/issues/5158#issuecomment-2287156946
self._repeated_messages_count = 1
return
@ -789,7 +825,10 @@ class CustomStreamWrapper:
raise e
def model_response_creator(
self, chunk: dict | None = None, hidden_params: Mapping[str, object] | None = None
self,
chunk: dict | None = None,
hidden_params: Mapping[str, object] | None = None,
delta: Delta | None = None,
) -> ModelResponseStream:
_model: Final = self._cached_model_name
_logging_obj_llm_provider: Final = self._cached_logging_llm_provider
@ -802,7 +841,11 @@ class CustomStreamWrapper:
if chunk:
args.update({k: v for k, v in chunk.items() if k != "stream"})
model_response: Final = ModelResponseStream(**args)
model_response: Final = (
ModelResponseStream(**args)
if delta is None
else ModelResponseStream(**args, choices=[StreamingChoices(delta=delta)])
)
if self.response_id is not None:
model_response.id = self.response_id
elif model_response.id:
@ -943,6 +986,39 @@ class CustomStreamWrapper:
model_response.choices[0].delta = Delta(**_initial_delta)
return model_response
def _prebuilt_plain_delta(self, chunk: object) -> Delta | None:
if (
not self._openai_handler_receives_chat_chunks
or not isinstance(chunk, ChatCompletionChunk)
or not chunk.choices
):
return None
upstream_delta: Final = cast( # cast-ok: [LIT006] runtime delta may be absent
ChoiceDelta | None,
chunk.choices[0].delta,
)
if (
upstream_delta is None
or upstream_delta.function_call is not None
or upstream_delta.tool_calls is not None
or self._has_any_special_delta_attributes(upstream_delta)
):
return None
return _delta_from_upstream(upstream_delta)
def _set_role_on_fresh_delta(self, model_response: ModelResponseStream) -> None:
delta: Final = model_response.choices[0].delta
if self.sent_first_chunk is False:
delta.role = "assistant"
self.sent_first_chunk = True
return
if not _holds_only_scalars(delta):
self.strip_role_from_delta(model_response)
return
delta.role = None
if "provider_specific_fields" not in delta.model_fields_set:
delta.provider_specific_fields = None
def _has_special_delta_content(self, model_response: ModelResponseStream) -> bool:
"""
Check if the delta contains special content types (tool_calls, function_call, audio, or image).
@ -975,11 +1051,11 @@ class CustomStreamWrapper:
"""
return self.strip_role_from_delta(model_response)
def _has_special_delta_attribute(self, delta, attribute_name: str) -> bool:
def _has_special_delta_attribute(self, delta: object, attribute_name: str) -> bool:
"""
Check if delta has a specific attribute and it's not None.
"""
return delta is not None and getattr(delta, attribute_name, None) is not None
return delta is not None and stored_attribute(delta, attribute_name) is not None
def _copy_delta_attribute(self, source_delta, target_delta, attribute_name: str) -> None:
"""
@ -987,7 +1063,7 @@ class CustomStreamWrapper:
"""
setattr(target_delta, attribute_name, getattr(source_delta, attribute_name))
def _has_any_special_delta_attributes(self, delta) -> bool:
def _has_any_special_delta_attributes(self, delta: object) -> bool:
"""
Check if delta has any special attributes (audio, image).
"""
@ -1011,6 +1087,7 @@ class CustomStreamWrapper:
completion_obj: dict[str, Any],
model_response: ModelResponseStream,
response_obj: dict[str, Any],
plain_delta_created: bool = False,
):
from litellm.litellm_core_utils.core_helpers import (
preserve_upstream_non_openai_attributes,
@ -1030,32 +1107,66 @@ class CustomStreamWrapper:
original_chunk: Final = response_obj.get("original_chunk", None)
if original_chunk:
if len(original_chunk.choices) > 0:
choices: Final = []
for choice in original_chunk.choices:
try:
if isinstance(choice, BaseModel):
choice_json = choice.model_dump()
choice_json.pop(
"finish_reason", None
) # for mistral etc. which return a value in their last chunk (not-openai compatible).
choices.append(StreamingChoices(**choice_json))
except Exception:
choices.append(StreamingChoices())
setattr(model_response, "choices", choices)
original_chunk_for_reuse: Final[object] = cast( # cast-ok: runtime type-check before access
object,
original_chunk,
)
upstream_choice: Final = (
original_chunk_for_reuse.choices[0]
if plain_delta_created
and isinstance(original_chunk_for_reuse, ChatCompletionChunk)
and len(original_chunk_for_reuse.choices) == 1
else None
)
upstream_delta: Final = cast( # cast-ok: [LIT006] runtime delta may be absent
ChoiceDelta | None,
getattr(upstream_choice, "delta", None),
)
upstream_choice_extra_keys: Final = frozenset(
(upstream_choice.__pydantic_extra__ or {}) if upstream_choice is not None else ()
)
can_reuse_choice: Final = (
upstream_choice is not None
and upstream_delta is not None
and "enhancements" not in upstream_choice_extra_keys
and upstream_choice.logprobs is None
and _holds_only_scalars(upstream_delta)
)
if can_reuse_choice and upstream_choice is not None:
model_response.choices[0].finish_reason = None # rebind-ok: keep reused choice
model_response.choices[0].index = upstream_choice.index # rebind-ok: keep reused choice
_prepend_upstream_choice_extras(
model_response.choices[0],
upstream_choice,
upstream_choice_extra_keys - {"message"},
)
else:
choices: Final = []
for choice in original_chunk.choices:
try:
if isinstance(choice, BaseModel):
choice_json = choice.model_dump()
choice_json.pop(
"finish_reason", None
) # for mistral etc. which return a value in their last chunk (not-openai compatible).
choices.append(StreamingChoices(**choice_json))
except Exception:
choices.append(StreamingChoices())
setattr(model_response, "choices", choices)
else:
return
model_response.system_fingerprint = original_chunk.system_fingerprint
setattr(
model_response,
"citations",
getattr(original_chunk, "citations", None),
stored_attribute(original_chunk, "citations"),
)
preserve_upstream_non_openai_attributes(
model_response=model_response,
original_chunk=original_chunk,
)
model_response = self.strip_role_from_delta(model_response)
self._set_role_on_fresh_delta(model_response)
if verbose_logger.isEnabledFor(logging.DEBUG):
verbose_logger.debug(
"model_response.choices[0].delta: %s",
@ -1404,6 +1515,8 @@ class CustomStreamWrapper:
if response_obj["is_finished"]:
self.received_finish_reason = response_obj["finish_reason"]
else: # openai / azure chat model
if isinstance(chunk, ChatCompletionChunk):
self._openai_handler_receives_chat_chunks = True
if self.custom_llm_provider in [
LlmProviders.AZURE.value,
LlmProviders.AZURE_AI.value,
@ -1468,8 +1581,11 @@ class CustomStreamWrapper:
provider_response_model: Final = _provider_response_model(chunk)
if provider_response_model is not None:
self._provider_response_model = provider_response_model
prebuilt_delta: Final = self._prebuilt_plain_delta(chunk)
plain_delta_created: bool = prebuilt_delta is not None # rebind-ok: the first chunk builds its delta below
model_response = self.model_response_creator(
hidden_params=_provider_hidden_params(chunk, self._provider_response_model)
hidden_params=_provider_hidden_params(chunk, self._provider_response_model),
delta=prebuilt_delta,
)
response_obj: dict[str, Any] = {}
try:
@ -1493,7 +1609,7 @@ class CustomStreamWrapper:
# enter this branch when no content has been passed in response
if hasattr(original_chunk, "id"):
model_response = self.set_model_id(original_chunk.id, model_response)
if hasattr(original_chunk, "provider_specific_fields"):
if stored_attribute(original_chunk, "provider_specific_fields") is not None:
model_response = self.copy_model_response_level_provider_specific_fields(
original_chunk, model_response
)
@ -1545,16 +1661,10 @@ class CustomStreamWrapper:
model_response.choices[0].delta = Delta()
elif self._has_any_special_delta_attributes(delta):
self._handle_special_delta_attributes(delta, model_response)
else:
try:
delta = (
dict()
if original_chunk.choices[0].delta is None
else dict(original_chunk.choices[0].delta)
)
model_response.choices[0].delta = Delta(**delta)
except Exception:
model_response.choices[0].delta = Delta()
elif prebuilt_delta is None:
plain_delta: Final = _delta_from_upstream(delta)
model_response.choices[0].delta = plain_delta if plain_delta is not None else Delta()
plain_delta_created = plain_delta is not None # rebind-ok: the first chunk builds its delta
else:
if self.send_stream_usage is True:
model_response.choices = []
@ -1581,6 +1691,7 @@ class CustomStreamWrapper:
completion_obj=completion_obj,
model_response=model_response,
response_obj=response_obj,
plain_delta_created=plain_delta_created,
)
return result
@ -1825,6 +1936,51 @@ class CustomStreamWrapper:
response.hidden_params["additional_headers"] = {}
response.hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] = _cost
def _builder_failure_response(self, error: Exception) -> ModelResponseStream | None:
verbose_logger.warning(
"stream_chunk_builder raised at end-of-stream (%s); logging best-effort usage from chunks.",
str(error),
)
try:
return self.model_response_creator(chunk={"usage": calculate_total_usage(chunks=self.chunks)})
except Exception:
return None
def _build_complete_streaming_response(self) -> _CompleteStreamingResponse:
try:
return litellm.stream_chunk_builder(
chunks=self.chunks,
messages=self.messages,
logging_obj=self.logging_obj,
count_prompt_tokens=self.count_prompt_tokens,
)
except Exception as e:
return self._builder_failure_response(e)
async def _async_build_complete_streaming_response(self) -> _CompleteStreamingResponse:
try:
return await asyncify(litellm.stream_chunk_builder)(
chunks=self.chunks,
messages=self.messages,
logging_obj=self.logging_obj,
count_prompt_tokens=self.count_prompt_tokens,
)
except Exception as e:
return self._builder_failure_response(e)
def _set_final_chunk_usage(self, chunk: ModelResponseStream) -> None:
reported_usage: Final = _reported_total_usage(chunks=self.chunks)
set_hidden_param(
chunk,
"usage",
UnreportedUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0)
if reported_usage is None
else reported_usage,
)
self._last_returned_hidden_params = cast( # cast-ok: preserve dynamic mapping behavior
dict[str, object], getattr(chunk, HIDDEN_PARAMS_ATTR)
)
def __next__(self) -> "ModelResponseStream":
if self._closed:
raise StopIteration
@ -1905,12 +2061,7 @@ class CustomStreamWrapper:
continue
# add usage as hidden param
if self.sent_last_chunk is True and self.stream_options is None:
usage = calculate_total_usage(chunks=self.chunks)
response_hidden_params_for_final_chunk = cast( # cast-ok: preserve dynamic mapping behavior
dict[str, object], getattr(response, HIDDEN_PARAMS_ATTR)
)
response_hidden_params_for_final_chunk["usage"] = usage
self._last_returned_hidden_params = response_hidden_params_for_final_chunk
self._set_final_chunk_usage(response)
# Add MCP metadata to final chunk if present
response = self._add_mcp_metadata_to_final_chunk(response)
# RETURN RESULT
@ -1918,30 +2069,7 @@ class CustomStreamWrapper:
except StopIteration:
if self.sent_last_chunk is True:
try:
complete_streaming_response = litellm.stream_chunk_builder(
chunks=self.chunks,
messages=self.messages,
logging_obj=self.logging_obj,
count_prompt_tokens=self.count_prompt_tokens,
)
except Exception as e:
# stream_chunk_builder can re-raise (as APIError) on large agentic
# streams. The raise originates inside this except-StopIteration block,
# so the sibling `except Exception` below does not catch it; it would
# escape __next__ and drop the request from SpendLogs. Recover
# best-effort usage from the raw chunks so cost is still tracked
verbose_logger.warning(
"stream_chunk_builder raised at end-of-stream (%s); logging best-effort usage from chunks.",
str(e),
)
try:
complete_streaming_response = self.model_response_creator(
chunk={"usage": calculate_total_usage(chunks=self.chunks)}
)
except Exception:
complete_streaming_response = None
complete_streaming_response: Final = self._build_complete_streaming_response()
response = self.model_response_creator()
if complete_streaming_response is not None:
self._propagate_usage_cost_to_hidden_params(complete_streaming_response, self.custom_llm_provider)
@ -2005,9 +2133,8 @@ class CustomStreamWrapper:
# last content chunk (stripped there), but never add the synthetic "stop" used when it sent none.
if self.received_finish_reason is not None or self.intermittent_finish_reason is not None:
self.chunks.append(processed_chunk)
if self.stream_options is None: # add usage as hidden param
usage = calculate_total_usage(chunks=self.chunks)
processed_chunk.hidden_params["usage"] = usage
if self.stream_options is None:
self._set_final_chunk_usage(processed_chunk)
## LOGGING
executor.submit(
self.run_success_logging_and_cache_storage,
@ -2122,12 +2249,7 @@ class CustomStreamWrapper:
# add usage as hidden param
if self.sent_last_chunk is True and self.stream_options is None:
usage = calculate_total_usage(chunks=self.chunks)
processed_chunk_hidden_params = cast( # cast-ok: preserve dynamic mapping behavior
dict[str, object], getattr(processed_chunk, HIDDEN_PARAMS_ATTR)
)
processed_chunk_hidden_params["usage"] = usage
self._last_returned_hidden_params = processed_chunk_hidden_params
self._set_final_chunk_usage(processed_chunk)
# Call post-call streaming deployment hook for final chunk
if self.sent_last_chunk is True:
@ -2183,29 +2305,7 @@ class CustomStreamWrapper:
async def _finalize_completed_stream(self, cache_hit: bool) -> "ModelResponseStream":
if self.sent_last_chunk is True:
# log the final chunk with accurate streaming values
try:
complete_streaming_response = await asyncify(litellm.stream_chunk_builder)(
chunks=self.chunks,
messages=self.messages,
logging_obj=self.logging_obj,
count_prompt_tokens=self.count_prompt_tokens,
)
except Exception as e:
# see sync __next__: a raise from stream_chunk_builder inside this
# except handler escapes __anext__ and drops the request from SpendLogs.
# Recover best-effort usage from the raw chunks so cost is still tracked
verbose_logger.warning(
"stream_chunk_builder raised at end-of-stream (%s); logging best-effort usage from chunks.",
str(e),
)
try:
complete_streaming_response = self.model_response_creator(
chunk={"usage": calculate_total_usage(chunks=self.chunks)}
)
except Exception:
complete_streaming_response = None
complete_streaming_response: Final = await self._async_build_complete_streaming_response()
response: Final = self.model_response_creator()
if complete_streaming_response is not None:
self._propagate_usage_cost_to_hidden_params(complete_streaming_response, self.custom_llm_provider)
@ -2282,11 +2382,7 @@ class CustomStreamWrapper:
if self.received_finish_reason is not None or self.intermittent_finish_reason is not None:
self.chunks.append(processed_chunk)
if self.stream_options is None:
usage: Final = calculate_total_usage(chunks=self.chunks)
processed_chunk_hidden_params: Final = cast( # cast-ok: preserve dynamic mapping behavior
dict[str, object], getattr(processed_chunk, HIDDEN_PARAMS_ATTR)
)
processed_chunk_hidden_params["usage"] = usage
self._set_final_chunk_usage(processed_chunk)
# see sync __next__'s sibling branch: deliberately do NOT restore
# here - this chunk is still this call's own data, and restoring
# before returning it would corrupt the caller's own log
@ -2557,7 +2653,13 @@ def _coerce_token_details(
return details_type(**(raw if isinstance(raw, dict) else raw.model_dump()))
def calculate_total_usage(chunks: list[ModelResponse]) -> Usage:
def _reported_total_usage(chunks: Sequence[ModelResponse | ModelResponseStream]) -> Usage | None:
if not any("usage" in chunk and chunk["usage"] is not None for chunk in chunks):
return None
return calculate_total_usage(chunks=chunks)
def calculate_total_usage(chunks: Sequence[ModelResponse | ModelResponseStream]) -> Usage:
"""Assume most recent usage chunk has total usage uptil then."""
from litellm.litellm_core_utils.streaming_chunk_builder_utils import (
attach_cache_creation_token_details,

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -30,6 +30,10 @@ from litellm.llms.anthropic.pass_through.adapters.transformation import (
LiteLLMAnthropicMessagesAdapter,
is_provider_native_tool_dict,
)
from litellm.llms.anthropic.pass_through.convertible_blocks import (
keep_non_string_tool_result_texts,
scannable_tool_result_request,
)
from litellm.llms.anthropic.pass_through.messages.utils import filtered_safeguard_results, redacted_stop_details
from litellm.llms.anthropic.pass_through.stream_assembly import (
build_complete_streaming_response,
@ -493,8 +497,8 @@ class AnthropicMessagesHandler(BaseTranslation):
``get_streaming_string_so_far``): raw SSE ``bytes`` -- which may bundle
several events separated by a blank line -- and an already-parsed event
``dict``."""
if isinstance(item, dict):
return (_as_str_mapping(item),)
if (event := as_str_mapping(item)) is not None:
return (event,)
if not isinstance(item, (bytes, bytearray)):
return ()
return tuple(
@ -510,7 +514,7 @@ class AnthropicMessagesHandler(BaseTranslation):
chat_completion_compatible_request,
_tool_name_mapping,
) = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
anthropic_message_request=cast(AnthropicMessagesRequest, data.copy()),
anthropic_message_request=cast(AnthropicMessagesRequest, scannable_tool_result_request(data)),
preserve_midturn_system=True,
)
return chat_completion_compatible_request
@ -832,7 +836,7 @@ class AnthropicMessagesHandler(BaseTranslation):
@staticmethod
def _write_back_structured_messages(
data: dict, # mutable-ok: API message payload
data: dict[str, object], # mutable-ok: API message payload
structured_messages: list, # mutable-ok: API message payload
hoisted_system_message: object = None,
preserve_system_messages: bool = False,
@ -899,7 +903,7 @@ class AnthropicMessagesHandler(BaseTranslation):
for block in content:
if isinstance(block, dict) and block.get("type") == "thinking":
block.pop("cache_control", None)
data["messages"] = converted
data["messages"] = keep_non_string_tool_result_texts(converted, data.get("messages"))
@staticmethod
def _extract_midturn_system_text(
@ -1601,8 +1605,8 @@ class AnthropicMessagesHandler(BaseTranslation):
@staticmethod
def _rewrite_buffered_item(item: object, rewrite_event: _SSEEventRewriter) -> object:
if isinstance(item, dict):
return _rewritten_event(_as_str_mapping(item), rewrite_event)
if (event := as_str_mapping(item)) is not None:
return _rewritten_event(event, rewrite_event)
if isinstance(item, (bytes, bytearray)):
return AnthropicMessagesHandler._rewrite_sse_events(bytes(item), rewrite_event)
return item

View file

@ -28,6 +28,7 @@ from litellm.llms.base_llm.base_utils import (
ResponseFormatSchema,
names_a_response_format_property,
partial_response_format_answer,
response_format_answer_json,
response_format_envelope_contents,
split_response_format_answer,
)
@ -104,17 +105,17 @@ def _key_after_a_string_verdict(string_value_start: str, schema: ResponseFormatS
next_key: Final = _STRING_VALUE_THEN_KEY.match(string_value_start)
if next_key is None:
return "undecided"
return "pass_through" if next_key.group(1) in schema.property_names else "hold"
return "pass_through" if next_key.group(1) in schema.declared_names else "hold"
def _is_a_label(text: str, schema: ResponseFormatSchema, tool_name: str) -> bool:
return text in schema.property_names or text == tool_name
return text in schema.declared_names or text == tool_name
def _label_verdict(label: str, next_key: str, schema: ResponseFormatSchema, tool_name: str) -> _EnvelopeVerdict:
if _is_a_label(label, schema, tool_name):
return "hold"
return "pass_through" if next_key in schema.property_names else "undecided"
return "pass_through" if next_key in schema.declared_names else "undecided"
def _leading_labels_verdict(string_value_start: str, schema: ResponseFormatSchema, tool_name: str) -> _EnvelopeVerdict:
@ -159,9 +160,9 @@ def _prefix_verdict(buffered_json: str, schema: ResponseFormatSchema, tool_name:
return "hold"
if first_value is None and _NAME_VALUE_PREFIX.match(buffered_json):
return "undecided"
if first_key.group(1) == "values" and "values" not in schema.property_names:
if first_key.group(1) == "values" and "values" not in schema.declared_names:
return "hold"
if first_key.group(1) in schema.property_names or not schema.property_names:
if first_key.group(1) in schema.declared_names or not schema.declared_names:
return "pass_through"
return _first_value_verdict(buffered_json[first_key.end() :], schema, tool_name)
@ -172,13 +173,13 @@ def _held_answer(held: str, schema: ResponseFormatSchema) -> str:
except (json.JSONDecodeError, RecursionError):
return held
contents: Final = response_format_envelope_contents(payload, schema, RESPONSE_FORMAT_TOOL_NAME)
return held if contents is None else json.dumps(contents)
return held if contents is None else response_format_answer_json(contents)
def _split_answer_text(blocks: tuple[str, ...], schema: ResponseFormatSchema) -> str:
answer: Final = split_response_format_answer(blocks, schema)
if answer is not None:
return json.dumps(answer)
return response_format_answer_json(answer)
return "".join(_held_answer(block, schema) for block in blocks)
@ -1283,7 +1284,7 @@ class ModelResponseIterator:
return None
self._streamed_answer_keys = streamed | frozenset(fields)
separator: Final = ", " if streamed else ""
return f"{separator}{json.dumps(fields)[1:-1]}" if fields else ""
return f"{separator}{response_format_answer_json(fields)[1:-1]}" if fields else ""
def _flush_response_format_buffer(self) -> str:
schema: Final = self.response_format_schema
@ -1314,6 +1315,17 @@ class ModelResponseIterator:
return ""
return _split_answer_text(blocks, self.response_format_schema)
def cut_stream_chunk(self) -> ModelResponseStream | None:
unsent: Final = self._flush_response_format_buffer() if self._response_format_buffer.getvalue() else ""
text: Final = unsent + self._release_answer_close() + self._drain_split_answer_blocks()
if not text:
return None
return ModelResponseStream(
choices=[StreamingChoices(index=0, delta=Delta(content=text))],
id=self.response_id,
model=self.served_model,
)
def _handle_message_delta(
self, chunk: dict
) -> tuple[str, Usage | None, dict[str, Any] | None, Mapping[str, object]]:
@ -1428,6 +1440,9 @@ class ModelResponseIterator:
result = self._handle_accumulated_json_chunk(data_str="", is_final=True)
if result is not None:
return result
cut_stream_chunk = self.cut_stream_chunk()
if cut_stream_chunk is not None:
return cut_stream_chunk
raise StopIteration
except ValueError as e:
raise RuntimeError(f"Error receiving chunk from stream: {e}")
@ -1474,6 +1489,9 @@ class ModelResponseIterator:
result = self._handle_accumulated_json_chunk(data_str="", is_final=True)
if result is not None:
return result
cut_stream_chunk = self.cut_stream_chunk()
if cut_stream_chunk is not None:
return cut_stream_chunk
raise StopAsyncIteration
except ValueError as e:
raise RuntimeError(f"Error receiving chunk from stream: {e}")

View file

@ -23,7 +23,7 @@ from litellm.constants import (
DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET,
RESPONSE_FORMAT_TOOL_NAME,
)
from litellm.litellm_core_utils.core_helpers import map_finish_reason
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, map_finish_reason
from litellm.litellm_core_utils.prompt_templates.common_utils import (
DEFS_MAX_INLINED_BYTES,
is_object_schema,
@ -50,6 +50,7 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
from litellm.llms.base_llm.base_utils import (
ResponseFormatSchema,
as_json_object,
response_format_answer_json,
response_format_schema,
split_response_format_answer,
type_to_response_format_param,
@ -125,6 +126,7 @@ from ..common_utils import (
refusal_provider_specific_fields,
requires_native_compaction_beta,
strip_advisor_blocks_from_messages,
tools_marked_for_a_tool_config_point,
)
if TYPE_CHECKING:
@ -162,6 +164,7 @@ _ANTHROPIC_TOOL_NAME_MAX_LEN: Final = 128
_DEFER_LOADING_ADAPTER: Final = TypeAdapter(bool)
_TOOL_FIELDS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_TOOLS_ADAPTER: Final = TypeAdapter(list[object])
_LITELLM_PARAMS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_NATIVE_OUTPUT_STRING_FORMATS: Final = frozenset(
("date-time", "time", "date", "duration", "email", "hostname", "uri", "ipv4", "ipv6", "uuid")
)
@ -2264,6 +2267,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
anthropic_messages_pt,
)
drop_params: Final = litellm.drop_params is True or litellm_params.get("drop_params") is True
if "tools" not in optional_params and messages is not None and has_tool_call_blocks(messages):
optional_params["tools"], _ = self._map_tools(add_dummy_tool(custom_llm_provider="anthropic"))
@ -2290,7 +2295,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
AnthropicConfig._maybe_drop_speed_param(
model=model,
optional_params=optional_params,
drop_params=litellm.drop_params or litellm_params.get("drop_params") is True,
drop_params=drop_params,
custom_llm_provider=self.custom_llm_provider,
)
@ -2348,6 +2353,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
model=model,
messages=list(conversation),
llm_provider=self._resolved_provider,
drop_params=drop_params,
)
except Exception as e:
raise AnthropicError(
@ -2408,6 +2414,19 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
# Remove internal LiteLLM parameters that should not be sent to Anthropic API
optional_params.pop("is_vertex_request", None)
optional_params.pop("client_metadata", None)
request_params: Final = cast( # cast-ok: optional_params is the untyped dict of the shared transform signature
dict[str, object], optional_params
)
marked_tools: Final = tools_marked_for_a_tool_config_point(
request_params.get("tools"), request_params.pop("cache_control_injection_points", None)
)
if marked_tools is not None:
from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook
request_litellm_params: Final = cast( # cast-ok: litellm_params is the untyped dict of the shared signature
Mapping[str, object], litellm_params
)
AnthropicCacheControlHook.record_gateway_injection(request_litellm_params, 1)
# ``top_k`` is a provider-specific kwarg that bypasses
# ``map_openai_params``; gate it here, the single boundary shared by
@ -2422,11 +2441,19 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
drop_params=litellm_params.get("drop_params") is True,
output_key="top_k",
)
output_format: Final = request_params.get("output_format")
if (
output_format is not None
and not isinstance(output_format, Mapping)
and drops_unsupported_params(_LITELLM_PARAMS_ADAPTER.validate_python(litellm_params).get("drop_params"))
):
request_params.pop("output_format")
data: Final = {
"model": model,
"messages": anthropic_messages,
**optional_params,
**({} if marked_tools is None else {"tools": list(marked_tools)}),
}
self._apply_output_config(data=data, model=model, optional_params=optional_params)
@ -3151,7 +3178,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
)
)
if split_answer is not None:
return litellm.Message(content=json.dumps(split_answer))
return litellm.Message(content=response_format_answer_json(split_answer))
try:
payload: Final = _DECODED_JSON.validate_python(json.loads(json_mode_content_str))
except (json.JSONDecodeError, RecursionError):
@ -3159,7 +3186,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
return litellm.Message(content=json_mode_content_str)
if response_format_schema is not None:
return litellm.Message(
content=json.dumps(
content=response_format_answer_json(
unwrap_response_format_envelope(payload, response_format_schema, RESPONSE_FORMAT_TOOL_NAME)
)
)

View file

@ -34,6 +34,7 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
message_field,
parts_of,
)
from litellm.litellm_core_utils.reasoning_effort_utils import non_numeric_thinking_budget_error
from litellm.llms.anthropic.wif import (
aget_anthropic_wif_token,
anthropic_base_without_chat_suffix,
@ -50,6 +51,7 @@ from litellm.types.llms.anthropic import (
ANTHROPIC_OAUTH_TOKEN_PREFIX,
ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX,
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
ANTHROPIC_TOOL_SEARCH_TOOL_TYPES,
AllAnthropicToolsValues,
AnthropicMcpServerTool,
AnthropicMessagesToolChoice,
@ -111,7 +113,7 @@ _DATED_RELEASE_SUFFIX_RE: Final = re.compile(r"-\d{8}$")
_DOTTED_VERSION_RE: Final = re.compile(r"(\d)\.(\d)")
_CLAUDE_CODE_BILLING_HEADER_PREFIX: Final = "x-anthropic-billing-header:"
OBJECT_MAPPING_ADAPTER: Final = TypeAdapter(dict[object, object])
_CLAUDE_CODE_OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object])
OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object])
_DECODED_JSON: Final = TypeAdapter(object)
@ -160,6 +162,40 @@ def supports_anthropic_cache_control(model: str, custom_llm_provider: str | None
)
def _tool_config_point_control(points: object) -> Mapping[object, object] | None:
point_fields: Final = tuple(validated_object_mapping(point) for point in validated_object_list(points) or ())
tool_config_point: Final = next(
(fields for fields in point_fields if fields is not None and fields.get("location") == "tool_config"), None
)
if tool_config_point is None:
return None
control: Final = tool_config_point.get("control")
return {"type": "ephemeral"} if control is None else validated_object_mapping(control)
def tools_marked_for_a_tool_config_point(tools: object, points: object) -> tuple[object, ...] | None:
"""Put the ``tool_config`` point's breakpoint on the last tool Anthropic caches.
Anthropic caches tools as one prefix ending at the marked tool, so the last one carries
the mark, skipping tool-search tools whose transform drops cache markers. A client that
already marked a tool keeps its own breakpoint. Returns ``None`` when nothing is marked.
"""
control: Final = _tool_config_point_control(points)
tool_list: Final = validated_object_list(tools) or ()
tool_fields: Final = tuple(validated_object_mapping(tool) for tool in tool_list)
if control is None or any(fields is not None and "cache_control" in fields for fields in tool_fields):
return None
cacheable: Final = tuple(
(position, fields)
for position, fields in enumerate(tool_fields)
if fields is not None and fields.get("type") not in ANTHROPIC_TOOL_SEARCH_TOOL_TYPES
)
if not cacheable:
return None
position, fields = cacheable[-1]
return (*tool_list[:position], {**fields, "cache_control": control}, *tool_list[position + 1 :])
def is_claude_code_user_agent(user_agent: str) -> bool:
"""Claude Code sends its API calls through the Anthropic SDK as `claude-cli/<version>` and its own
fetches, such as gateway model discovery, as `claude-code/<version>`"""
@ -173,9 +209,9 @@ def validated_object_mapping(value: object) -> dict[object, object] | None:
return None
def _validated_claude_code_list(value: object) -> list[object] | None:
def validated_object_list(value: object) -> Sequence[object] | None:
try:
return _CLAUDE_CODE_OBJECT_LIST_ADAPTER.validate_python(value)
return OBJECT_LIST_ADAPTER.validate_python(value)
except ValidationError:
return None
@ -202,7 +238,7 @@ def _claude_code_billing_fields(text: str) -> tuple[tuple[str, str], ...] | None
def _claude_code_billing_texts(system: object) -> tuple[str, ...] | None:
if isinstance(system, str):
return (system,)
blocks: Final = _validated_claude_code_list(system)
blocks: Final = validated_object_list(system)
if blocks is None:
return None
block_mappings: Final = tuple(validated_object_mapping(block) for block in blocks)
@ -462,6 +498,27 @@ def _fetch_anthropic_model_ids(
raise Exception(f"Anthropic /v1/models did not terminate within {_MODEL_LIST_PAGE_CAP} pages.")
def _numeric_budget_tokens(budget_tokens: object) -> int | None:
if isinstance(budget_tokens, bool):
return None
if isinstance(budget_tokens, (int, float)):
return int(budget_tokens)
if not isinstance(budget_tokens, str):
return None
try:
return int(budget_tokens)
except ValueError:
return None
def _legacy_budget_tokens(budget_tokens: object, *, drop_params: bool) -> int | None:
if (numeric := _numeric_budget_tokens(budget_tokens)) is not None:
return numeric
if drop_params or litellm.drop_params is True:
return None
raise non_numeric_thinking_budget_error(budget_tokens)
class AnthropicModelInfo(BaseLLMModelInfo):
_workload_identity_eligible: ClassVar[bool] = True
@ -655,7 +712,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
optional_params: dict,
model: str,
param: str,
value: Any,
value: object,
drop_params: bool,
output_key: str,
) -> None:
@ -942,19 +999,19 @@ class AnthropicModelInfo(BaseLLMModelInfo):
if thinking is None or thinking.get("type") != "enabled":
return
budget_tokens: Final = thinking.get("budget_tokens") or 0
effort: Final = AnthropicModelInfo._legacy_budget_to_effort(
model=model,
budget_tokens=int(budget_tokens) if isinstance(budget_tokens, (int, float, str)) else 0,
custom_llm_provider=custom_llm_provider,
)
existing_output_config: Final = optional_params.get("output_config")
budget_tokens: Final = _legacy_budget_tokens(thinking.get("budget_tokens", 0), drop_params=drop_params)
display: Final = thinking.get("display")
supported_display: Final = {"display": display} if display in ("summarized", "omitted") else {}
carried_keys: Final = carried_thinking_keys(
thinking, rebuilt_keys=frozenset(("type", "budget_tokens", "display")), drop_params=drop_params
)
optional_params["thinking"] = {**carried_keys, "type": "adaptive", **supported_display}
if budget_tokens is None:
return
effort: Final = AnthropicModelInfo._legacy_budget_to_effort(
model=model, budget_tokens=budget_tokens, custom_llm_provider=custom_llm_provider
)
existing_output_config: Final = optional_params.get("output_config")
optional_params["output_config"] = {
"effort": effort,
**(existing_output_config if isinstance(existing_output_config, dict) else MappingProxyType({})),
@ -1731,6 +1788,21 @@ def strip_thinking_blocks_from_anthropic_messages_request_dict(
data.pop("thinking", None)
_DROP_ANTHROPIC_MESSAGE: Final = object()
def _strip_empty_content_blocks_from_message(message: object) -> object:
if not isinstance(message, dict) or not isinstance(message.get("content"), list):
return message
content: Final = message["content"]
filtered: Final = [
block for block in content if not _is_empty_text_block(block) and not is_empty_unsigned_thinking_block(block)
]
if len(filtered) == len(content):
return message
return {**message, "content": filtered} if filtered else _DROP_ANTHROPIC_MESSAGE
def strip_empty_content_blocks_from_anthropic_messages(
messages: Sequence[object],
) -> list[Any]:
@ -1765,18 +1837,11 @@ def strip_empty_content_blocks_from_anthropic_messages(
The caller's list and its content blocks are never mutated; modified
messages are returned as shallow copies with a fresh content list.
"""
out: Final[list[Any]] = []
for m in messages:
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
out.append(m)
continue
content = m["content"]
filtered = [b for b in content if not _is_empty_text_block(b) and not is_empty_unsigned_thinking_block(b)]
if len(filtered) == len(content):
out.append(m)
elif filtered:
out.append({**m, "content": filtered})
return out
return [
stripped_message
for message in messages
if (stripped_message := _strip_empty_content_blocks_from_message(message)) is not _DROP_ANTHROPIC_MESSAGE
]
def _is_empty_text_block(block: object) -> bool:
@ -1864,18 +1929,15 @@ def sanitize_tool_use_ids_in_anthropic_messages(messages: Sequence[object]) -> l
and ``:`` — valid on the upstream provider but rejected by Anthropic when
the session is switched to a native Anthropic deployment.
"""
out: Final[list[Any]] = []
for m in messages:
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
out.append(m)
continue
content = m["content"]
new_content = [_sanitize_tool_use_id_content_block(b) for b in content]
if new_content == content:
out.append(m)
else:
out.append({**m, "content": new_content})
return out
return [_sanitize_tool_use_ids_in_message(message) for message in messages]
def _sanitize_tool_use_ids_in_message(message: object) -> object:
if not isinstance(message, dict) or not isinstance(message.get("content"), list):
return message
content: Final = message["content"]
new_content: Final = [_sanitize_tool_use_id_content_block(block) for block in content]
return message if new_content == content else {**message, "content": new_content}
class _ReplayedSearchQuery(LiteLLMBaseModel):
@ -1963,8 +2025,8 @@ def tool_call_is_rebuilt_as_server_tool_use(
return (
find_anthropic_server_tool_result(
tool_call_id,
_validated_claude_code_list(fields.get("web_search_results")),
_validated_claude_code_list(fields.get("tool_results")),
validated_object_list(fields.get("web_search_results")),
validated_object_list(fields.get("tool_results")),
)
is not None
)

View file

@ -2,6 +2,8 @@
Anthropic Token Counter implementation using the CountTokens API.
"""
from collections.abc import Mapping
from types import MappingProxyType
from typing import Any, Final
from pydantic import JsonValue
@ -10,6 +12,11 @@ import litellm
from litellm._logging import verbose_logger
from litellm.exceptions import AuthenticationError
from litellm.llms.anthropic.count_tokens.handler import AnthropicCountTokensHandler
from litellm.llms.anthropic.count_tokens.transformation import (
AnthropicCountFields,
ChatCountTranslationError,
anthropic_count_fields,
)
from litellm.llms.base_llm.base_utils import BaseTokenCounter
from litellm.types.utils import CountContextManagement, LlmProviders, TokenCountMessagesFormat, TokenCountResponse
@ -38,6 +45,8 @@ class AnthropicTokenCounter(BaseTokenCounter):
messages_format: TokenCountMessagesFormat = "anthropic",
output_format: JsonValue = None,
context_management: CountContextManagement | None = None,
chat_params: Mapping[str, JsonValue] = MappingProxyType({}),
count_options: Mapping[str, JsonValue] = MappingProxyType({}),
) -> TokenCountResponse | None:
"""
Count tokens using Anthropic's CountTokens API.
@ -57,6 +66,30 @@ class AnthropicTokenCounter(BaseTokenCounter):
if not messages:
return None
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
match translated := await anthropic_count_fields(
model_to_use, messages, system, tools, messages_format, AnthropicConfig(), chat_params
):
case ChatCountTranslationError():
return translated.response(request_model, model_to_use, "anthropic_api")
case AnthropicCountFields():
return await self.count_tokens(
model_to_use=model_to_use,
messages=[dict(message) for message in translated.messages],
contents=contents,
deployment=deployment,
request_model=request_model,
tools=[dict(tool) for tool in translated.tools] if translated.tools is not None else None,
system=translated.system,
messages_format="anthropic",
output_format=output_format,
context_management=context_management,
count_options=translated.options,
)
case None:
pass
deployment = deployment or {}
litellm_params: Final = deployment.get("litellm_params", {})
api_base: Final = litellm_params.get("api_base")
@ -89,6 +122,7 @@ class AnthropicTokenCounter(BaseTokenCounter):
api_base=api_base,
tools=tools,
system=system,
optional_params=count_options,
)
if result is not None:

View file

@ -4,25 +4,37 @@ Anthropic CountTokens API transformation logic.
This module handles the transformation of requests to Anthropic's CountTokens API format.
"""
import copy
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from itertools import chain
from types import MappingProxyType
from typing import Final
from typing import (
Final,
Protocol,
TypeAlias,
cast, # noqa: TID251 # caller chat messages go to chat inference translation as-is
)
from pydantic import JsonValue, TypeAdapter
from pydantic import ConfigDict, JsonValue, TypeAdapter, with_config
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.constants import ANTHROPIC_TOKEN_COUNTING_BETA_VERSION
from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
anthropic_system_blocks,
split_leading_system_run,
)
from litellm.llms.anthropic.common_utils import merge_anthropic_beta_headers
from litellm.llms.anthropic.wif import resolve_anthropic_base
from litellm.types.llms.openai import ChatCompletionImageObject
from litellm.types.llms.openai import AllMessageValues, ChatCompletionImageObject, ValidUserMessageContentTypes
from litellm.types.utils import TokenCountMessagesFormat, TokenCountResponse
_COUNT_REQUEST: Final = TypeAdapter(dict[str, JsonValue])
_SYSTEM_BLOCKS: Final = TypeAdapter(list[JsonValue])
_IMAGE_BLOCK: Final = TypeAdapter(ChatCompletionImageObject)
COUNT_TOKEN_OPTION_NAMES: Final = ("thinking", "tool_choice", "output_config")
_PLAIN_MESSAGE_KEYS: Final = frozenset({"role", "content"})
def _count_image(block: JsonValue) -> JsonValue:
@ -74,6 +86,213 @@ def _lift_leading_system(
return conversation, lifted or system
@dataclass(frozen=True, slots=True)
class AnthropicCountFields:
messages: tuple[Mapping[str, JsonValue], ...]
system: JsonValue
tools: tuple[Mapping[str, JsonValue], ...] | None
options: Mapping[str, JsonValue]
@dataclass(frozen=True, slots=True)
class ChatCountTranslationError:
message: str
def response(self, request_model: str, model_used: str, tokenizer_type: str) -> TokenCountResponse:
return TokenCountResponse(
total_tokens=0,
request_model=request_model,
model_used=model_used,
tokenizer_type=tokenizer_type,
error=True,
error_message=self.message,
status_code=400,
rejected_before_provider_call=True,
)
@with_config(ConfigDict(extra="ignore"))
class _AnthropicChatRequest(TypedDict):
messages: ReadOnly[tuple[Mapping[str, JsonValue], ...]]
system: NotRequired[ReadOnly[JsonValue]]
tools: NotRequired[ReadOnly[tuple[Mapping[str, JsonValue], ...]]]
thinking: NotRequired[ReadOnly[JsonValue]]
tool_choice: NotRequired[ReadOnly[JsonValue]]
output_config: NotRequired[ReadOnly[JsonValue]]
output_format: NotRequired[ReadOnly[JsonValue]]
_ANTHROPIC_CHAT_REQUEST: Final = TypeAdapter(_AnthropicChatRequest)
_ChatParams: TypeAlias = dict[str, object]
_ChatMessages: TypeAlias = list[AllMessageValues]
class _ChatRequestTranslator(Protocol):
def map_openai_params(
self, non_default_params: _ChatParams, optional_params: _ChatParams, model: str, drop_params: bool
) -> _ChatParams: ...
def transform_request(
self,
model: str,
messages: _ChatMessages,
optional_params: _ChatParams,
litellm_params: _ChatParams,
headers: _ChatParams,
) -> _ChatParams: ...
def _needs_chat_translation(message: Mapping[str, object]) -> bool:
return message.get("role") == "tool" or not _PLAIN_MESSAGE_KEYS.issuperset(message.keys())
def _is_chat_tool(tool: Mapping[str, object]) -> bool:
from litellm.litellm_core_utils.prompt_templates.factory import is_flat_responses_tool
return is_flat_responses_tool(tool) or (tool.get("type") == "function" and "function" in tool)
def _has_non_chat_user_block(message: Mapping[str, object]) -> bool:
content: Final = message.get("content")
if message.get("role") != "user" or not isinstance(content, list):
return False
blocks: Final = _SYSTEM_BLOCKS.validate_python(content)
return any(isinstance(block, dict) and block.get("type") not in ValidUserMessageContentTypes for block in blocks)
def _gets_placeholder_name(tool: Mapping[str, object]) -> bool:
from litellm.llms.anthropic.pass_through.adapters.transformation import is_provider_native_tool_dict
name: Final = tool.get("name")
return not (_is_chat_tool(tool) or is_provider_native_tool_dict(tool) or (isinstance(name, str) and name.strip()))
def _untranslatable_input(
messages: Sequence[Mapping[str, object]], tools: Sequence[Mapping[str, object]]
) -> ChatCountTranslationError | None:
invalid_index: Final = next(
(index for index, message in enumerate(messages) if _has_non_chat_user_block(message)), None
)
if invalid_index is not None:
return ChatCountTranslationError(
message=f"Invalid user message at index {invalid_index}. "
"Please ensure all user messages are valid OpenAI chat completion messages."
)
unnamed_tool: Final = next(filter(_gets_placeholder_name, tools), None)
if unnamed_tool is not None:
return ChatCountTranslationError(
message=f"Invalid request for token counting: tool type {unnamed_tool.get('type')!r} "
"has no Chat Completions equivalent"
)
return None
def _system_messages(system: object) -> tuple[Mapping[str, object], ...]:
message: Final[Mapping[str, object]] = {"role": "system", "content": system}
return (message,) if isinstance(system, (str, list)) and system else ()
def _count_options(request: _AnthropicChatRequest) -> Mapping[str, JsonValue]:
"""Structured output reaches chat inference as the beta ``output_format``, which count_tokens
refuses; its GA ``output_config.format`` form counts the same schema the same way."""
output_config: Final = request.get("output_config")
output_format: Final = request.get("output_format")
counted_config: Final = {
**(output_config if isinstance(output_config, dict) else {}),
**({} if output_format is None else {"format": output_format}),
}
return MappingProxyType(
{
key: value
for key, value in (
("thinking", request.get("thinking")),
("tool_choice", request.get("tool_choice")),
("output_config", counted_config or None),
)
if value is not None
}
)
def _translated_chat_request(
model: str,
messages: Sequence[Mapping[str, object]],
system: object,
tools: Sequence[Mapping[str, object]],
chat_params: Mapping[str, object],
translator: _ChatRequestTranslator,
) -> _ChatParams:
from litellm.litellm_core_utils.prompt_templates.factory import chat_completion_tools_for
chat_tools: Final = [dict(chat_tool) for chat_tool in chain.from_iterable(map(chat_completion_tools_for, tools))]
openai_params: Final = {name: value for name, value in chat_params.items() if name != "drop_params"}
mapped_params: Final = {**openai_params, **({"tools": chat_tools} if chat_tools else {})}
chat_messages: Final = cast( # cast-ok: chat-shaped request messages, read the way chat inference reads them
_ChatMessages, copy.deepcopy([*_system_messages(system), *messages])
)
return translator.transform_request(
model=model,
messages=chat_messages,
optional_params=translator.map_openai_params(
mapped_params, {}, model, drop_params=chat_params.get("drop_params") is True
)
if mapped_params
else {},
litellm_params={},
headers={},
)
def anthropic_fields_for_chat_tool_use(
model: str,
messages: Sequence[Mapping[str, object]],
system: object,
tools: Sequence[Mapping[str, object]] | None,
translator: _ChatRequestTranslator,
chat_params: Mapping[str, object] = MappingProxyType({}),
) -> AnthropicCountFields | ChatCountTranslationError | None:
counted_tools: Final = tools or ()
if (
not chat_params
and not any(map(_is_chat_tool, counted_tools))
and not any(map(_needs_chat_translation, messages))
):
return None
untranslatable: Final = _untranslatable_input(messages, counted_tools)
if untranslatable is not None:
return untranslatable
from litellm.exceptions import BadRequestError
from litellm.llms.anthropic.common_utils import AnthropicError
try:
request: Final = _translated_chat_request(model, messages, system, counted_tools, chat_params, translator)
except (AnthropicError, BadRequestError) as e:
return ChatCountTranslationError(message=str(e.message))
except (ValueError, KeyError) as e:
return ChatCountTranslationError(message=f"Invalid request for token counting: {e}")
translated: Final = _ANTHROPIC_CHAT_REQUEST.validate_python(request)
return AnthropicCountFields(
messages=translated["messages"],
system=translated.get("system"),
tools=translated.get("tools"),
options=_count_options(translated),
)
async def anthropic_count_fields(
model: str,
messages: Sequence[Mapping[str, object]],
system: object,
tools: Sequence[Mapping[str, object]] | None,
messages_format: TokenCountMessagesFormat,
translator: _ChatRequestTranslator,
chat_params: Mapping[str, object] = MappingProxyType({}),
) -> AnthropicCountFields | ChatCountTranslationError | None:
if messages_format != "openai":
return None
return await asyncify(anthropic_fields_for_chat_tool_use)(model, messages, system, tools, translator, chat_params)
class AnthropicCountTokensConfig:
"""
Configuration and transformation logic for Anthropic CountTokens API.

View file

@ -13,6 +13,7 @@ from typing_extensions import TypedDict
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
from litellm.llms.anthropic.pass_through.adapters.transformation import (
AnthropicAdapter,
@ -22,6 +23,7 @@ from litellm.llms.anthropic.pass_through.context_management import (
PolyfillResult,
apply_context_management,
)
from litellm.llms.anthropic.pass_through.convertible_blocks import reject_malformed_tool_result_text
from litellm.llms.anthropic.pass_through.safeguards import (
SafeguardsEvaluator,
build_safeguards_evaluator,
@ -329,8 +331,10 @@ async def _run_polyfill_if_enabled(
does not disable a valid spec because context_management is a supported
param). Raises ``AnthropicContextManagementError`` so the /v1/messages
endpoint can emit an Anthropic-format 400, unless ``drop_invalid_edits``
(``drop_params``) drops the invalid spec. All other exceptions are
best-effort swallowed (matches v0 behavior).
(``drop_params``) drops what is invalid: the malformed fields of a
``clear_tool_uses`` edit alone, or the whole spec for an invalid ``compact``
trigger. All other
exceptions are best-effort swallowed (matches v0 behavior).
"""
if not context_management_spec:
return None
@ -351,6 +355,7 @@ async def _run_polyfill_if_enabled(
sub_call_logging_kwargs=sub_call_logging_kwargs,
parent_trace_id=parent_trace_id,
count_only=count_only,
drop_invalid_edits=drop_invalid_edits,
)
except AnthropicContextManagementError:
if drop_invalid_edits:
@ -548,16 +553,25 @@ class LiteLLMMessagesToCompletionTransformationHandler:
model: Final = completion_kwargs.get("model", "")
custom_llm_provider: Final = completion_kwargs.get("custom_llm_provider")
deployment_model_info: Final = completion_kwargs.get("model_info")
if isinstance(reasoning_effort, str):
normalized = normalize_reasoning_effort_value(
reasoning_effort, model=model, custom_llm_provider=custom_llm_provider
reasoning_effort,
model=model,
custom_llm_provider=custom_llm_provider,
deployment_model_info=deployment_model_info,
)
if normalized != reasoning_effort:
completion_kwargs["reasoning_effort"] = normalized
elif isinstance(reasoning_effort, dict) and "effort" in reasoning_effort:
effort: Final = reasoning_effort["effort"]
normalized = normalize_reasoning_effort_value(effort, model=model, custom_llm_provider=custom_llm_provider)
normalized = normalize_reasoning_effort_value(
effort,
model=model,
custom_llm_provider=custom_llm_provider,
deployment_model_info=deployment_model_info,
)
if normalized != effort:
completion_kwargs["reasoning_effort"] = {
**reasoning_effort,
@ -638,6 +652,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
) = ANTHROPIC_ADAPTER.translate_completion_input_params_with_tool_mapping(
request_data,
custom_llm_provider=custom_llm_provider if isinstance(custom_llm_provider, str) else None,
drop_params=drops_unsupported_params(extra_kwargs.get("drop_params")),
)
if openai_request is None:
@ -724,13 +739,12 @@ class LiteLLMMessagesToCompletionTransformationHandler:
"""Handle non-Anthropic models asynchronously using the adapter"""
context_management: Final = kwargs.pop("context_management", None)
additional_drop_params: Final[list[str] | None] = kwargs.get("additional_drop_params", None)
drop_invalid_edits: Final = litellm.drop_params is True or (
"drop_params" in kwargs and kwargs["drop_params"] is True
)
litellm_router: Final[Router | None] = _router_or_proxy_fallback(kwargs.pop("litellm_router", None))
proxy_litellm_metadata, user_api_key_auth = _extract_proxy_litellm_metadata(kwargs)
request_kwargs: Final = _REQUEST_KWARGS.validate_python(kwargs)
reject_malformed_tool_result_text(messages, model, request_kwargs)
drop_invalid_edits: Final = drops_unsupported_params(request_kwargs.get("drop_params"))
sub_call_logging_kwargs: Final = proxy_sub_call_logging_kwargs(request_kwargs)
sub_call_trace_id: Final = proxy_sub_call_trace_id(request_kwargs)
@ -853,6 +867,9 @@ class LiteLLMMessagesToCompletionTransformationHandler:
**kwargs,
)
sync_request_kwargs: Final = _REQUEST_KWARGS.validate_python(kwargs)
reject_malformed_tool_result_text(messages, model, sync_request_kwargs)
# Run the context_management polyfill on the sync path too so that
# ``litellm.messages.create()`` callers don't silently lose edits like
# ``clear_tool_uses_20250919``. The dispatcher is async (so the
@ -860,9 +877,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
# bridge to it via ``run_async_function``.
context_management: Final = kwargs.pop("context_management", None)
additional_drop_params: Final[list[str] | None] = kwargs.get("additional_drop_params", None)
drop_invalid_edits: Final = litellm.drop_params is True or (
"drop_params" in kwargs and kwargs["drop_params"] is True
)
drop_invalid_edits: Final = drops_unsupported_params(sync_request_kwargs.get("drop_params"))
# Deliberately do NOT auto-attach the proxy ``llm_router`` here:
# ``run_async_function`` spawns a new event loop in a worker thread
# to bridge to the async dispatcher, but the proxy router's httpx

View file

@ -8,7 +8,9 @@ from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast
from pydantic import JsonValue, TypeAdapter
import litellm
from litellm.litellm_core_utils.core_helpers import normalize_drop_params
from litellm.litellm_core_utils.hidden_params import get_hidden_params
from litellm.llms.anthropic.pass_through.convertible_blocks import tool_result_text_part
from litellm.llms.anthropic.pass_through.utils import (
is_reasoning_auto_summary_enabled,
json_copy_or_original,
@ -129,7 +131,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
)
from litellm.litellm_core_utils.reasoning_effort_utils import (
reasoning_effort_from_thinking_budget,
reasoning_effort_from_thinking,
)
from litellm.llms.anthropic.common_utils import (
eager_input_streaming_flag,
@ -233,7 +235,7 @@ class AnthropicAdapter:
def __init__(self) -> None:
pass
def translate_completion_input_params(self, kwargs) -> ChatCompletionRequest | None:
def translate_completion_input_params(self, kwargs: Mapping[str, object]) -> ChatCompletionRequest | None:
"""
Translate Anthropic request params to OpenAI format.
@ -243,11 +245,14 @@ class AnthropicAdapter:
Note: Use translate_completion_input_params_with_tool_mapping() if you need
the tool name mapping for restoring original names in responses.
"""
result, _ = self.translate_completion_input_params_with_tool_mapping(kwargs)
result, _ = self.translate_completion_input_params_with_tool_mapping(
kwargs,
drop_params=litellm.drop_params is True or normalize_drop_params(kwargs.get("drop_params")) is True,
)
return result
def translate_completion_input_params_with_tool_mapping(
self, kwargs, *, custom_llm_provider: str | None = None
self, kwargs, *, custom_llm_provider: str | None = None, drop_params: bool = True
) -> tuple[ChatCompletionRequest | None, dict[str, str]]:
"""
Translate Anthropic request params to OpenAI format, returning tool name mapping.
@ -281,6 +286,7 @@ class AnthropicAdapter:
) = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
anthropic_message_request=request_body,
custom_llm_provider=custom_llm_provider,
drop_params=drop_params,
)
return translated_body, tool_name_mapping
@ -647,6 +653,8 @@ class LiteLLMAnthropicMessagesAdapter:
@staticmethod
def translate_anthropic_thinking_to_reasoning_effort(
thinking: AnthropicThinkingParam,
*,
drop_params: bool = True,
) -> str | None:
"""
Translate Anthropic's thinking parameter to OpenAI's reasoning_effort.
@ -665,7 +673,7 @@ class LiteLLMAnthropicMessagesAdapter:
if thinking_type == "disabled":
return "none"
elif thinking_type == "enabled":
return reasoning_effort_from_thinking_budget(thinking.get("budget_tokens", 0))
return reasoning_effort_from_thinking(thinking, drop_params=drop_params)
elif thinking_type == "adaptive":
# Adaptive thinking: effort is controlled by output_config.effort,
# not budget_tokens. Return a default; caller should override with
@ -1112,6 +1120,7 @@ class LiteLLMAnthropicMessagesAdapter:
new_kwargs: ChatCompletionRequest,
*,
custom_llm_provider: str | None = None,
drop_params: bool = True,
) -> None:
"""Translate Anthropic thinking to either thinking or reasoning_effort.
@ -1169,7 +1178,7 @@ class LiteLLMAnthropicMessagesAdapter:
return
reasoning_effort: Final = declared_effort or self.translate_anthropic_thinking_to_reasoning_effort(
cast(AnthropicThinkingParam, thinking)
cast(AnthropicThinkingParam, thinking), drop_params=drop_params
)
if not reasoning_effort:
return
@ -1227,6 +1236,7 @@ class LiteLLMAnthropicMessagesAdapter:
*,
custom_llm_provider: str | None = None,
preserve_midturn_system: bool = False,
drop_params: bool = True,
) -> tuple[ChatCompletionRequest, dict[str, str]]:
"""
This is used by the beta Anthropic Adapter, for translating anthropic `/v1/messages` requests to the openai format.
@ -1279,6 +1289,7 @@ class LiteLLMAnthropicMessagesAdapter:
anthropic_message_request=anthropic_message_request,
new_kwargs=new_kwargs,
custom_llm_provider=custom_llm_provider,
drop_params=drop_params,
)
## CONVERT STOP_SEQUENCES
self._translate_stop_sequences_to_openai(
@ -1334,7 +1345,8 @@ class LiteLLMAnthropicMessagesAdapter:
block: Final = cast(Mapping[str, object], item) # cast-ok: untrusted client payload
match block.get("type"):
case "text":
return ChatCompletionTextObject(type="text", text=str(block.get("text") or ""))
text: Final = tool_result_text_part(block)
return None if text is None else ChatCompletionTextObject(type="text", text=text)
case "image" | "document":
return self._tool_result_image_part(block.get("source"))
case "tool_reference":

View file

@ -9,7 +9,12 @@ from litellm.litellm_core_utils.asyncify import asyncify
from litellm.types.llms.anthropic import AppliedEdit
from .constants import CLEAR_TOOL_USES_EDIT_TYPE, COMPACT_EDIT_TYPE
from .editors import apply_clear_tool_uses_20250919, apply_compact_20260112
from .editors import (
CheckedEdit,
apply_clear_tool_uses_20250919,
apply_compact_20260112,
check_clear_tool_uses_spec,
)
from .result import PolyfillResult
if TYPE_CHECKING:
@ -73,6 +78,12 @@ def _wrap_editor_return(
)
def _checked_edit(index: int, edit_spec: dict[str, object]) -> CheckedEdit:
if edit_spec.get("type") != CLEAR_TOOL_USES_EDIT_TYPE:
return CheckedEdit(edit=edit_spec, error=None)
return check_clear_tool_uses_spec(edit_spec, index)
async def apply_context_management(
*,
model: str,
@ -86,9 +97,14 @@ async def apply_context_management(
sub_call_logging_kwargs: "ProxySubCallLoggingKwargs | None" = None,
parent_trace_id: str | None = None,
count_only: bool = False,
drop_invalid_edits: bool = False,
) -> PolyfillResult:
"""Run edits in order; return a single ``PolyfillResult``.
Every ``clear_tool_uses_20250919`` edit is validated before any editor runs, so a malformed one raises its 400
before a ``compact_20260112`` summary call is paid for, or runs without its malformed fields under
``drop_invalid_edits``.
The dispatcher is async so async editors (``compact_20260112``) can
``await`` the configured summarization model. Sync editors run in a
worker thread so their token counts stay off the event loop;
@ -97,6 +113,11 @@ async def apply_context_management(
edits: Final = normalize_spec(context_management_spec)
if not edits:
return PolyfillResult(messages=messages, system=system, applied_edits=[])
checked: Final = tuple(_checked_edit(index, edit_spec) for index, edit_spec in enumerate(edits))
first_error: Final = next((check.error for check in checked if check.error is not None), None)
if first_error is not None and not drop_invalid_edits:
raise first_error
well_formed_edits: Final = tuple(check.edit for check in checked)
current_messages = messages
current_system = system
@ -104,7 +125,7 @@ async def apply_context_management(
aggregated_compaction_block = None
aggregated_iterations_usage = None
for edit_spec in edits:
for edit_spec in well_formed_edits:
edit_type = edit_spec.get("type")
editor = _EDITOR_REGISTRY.get(edit_type) if isinstance(edit_type, str) else None
if editor is None:

View file

@ -1,4 +1,4 @@
from .clear_tool_uses import apply_clear_tool_uses_20250919
from .clear_tool_uses import CheckedEdit, apply_clear_tool_uses_20250919, check_clear_tool_uses_spec
from .compact import apply_compact_20260112
__all__ = ["apply_clear_tool_uses_20250919", "apply_compact_20260112"]
__all__ = ["CheckedEdit", "apply_clear_tool_uses_20250919", "apply_compact_20260112", "check_clear_tool_uses_spec"]

View file

@ -1,7 +1,23 @@
"""``clear_tool_uses_20250919`` polyfill (v0: ``trigger`` and ``keep`` only)."""
"""``clear_tool_uses_20250919`` polyfill, matching the Anthropic API's selection.
from typing import Any, Final, cast
``keep`` counts clearable tool uses only. Uses of ``exclude_tools`` are never cleared and never count toward a
``tool_uses`` trigger, and neither are uses whose result is 1 to 46 bytes of text, which the API leaves out of its
count, unless ``clear_tool_inputs`` covers their tool: then their input can still be cleared, and the API counts and
clears them like any other. Clearing goes oldest first in batches that free at least ``clear_at_least`` tokens,
counting the result and any input it clears, and leaves an incomplete last batch, so the cleared prefix only grows
between requests and each batch costs one prompt-cache miss. ``check_clear_tool_uses_spec`` answers a malformed field
the way the API's 400 does, so the dispatcher can reject the edit, or drop only its malformed fields, before any editor
runs.
"""
from collections.abc import Mapping, Sequence, Set
from dataclasses import dataclass
from functools import reduce
from itertools import chain
from types import MappingProxyType
from typing import Annotated, Any, Final, Literal, TypeVar, cast
from pydantic import BaseModel, ConfigDict, Field, StrictInt, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
import litellm
@ -13,8 +29,39 @@ from ..constants import (
DEFAULT_INPUT_TOKENS_TRIGGER,
DEFAULT_KEEP_TOOL_USES,
)
from ..errors import AnthropicContextManagementError
from ..placeholders import build_cleared_tool_result_content
T = TypeVar("T")
# api.anthropic.com, measured 2026-10-19: text results of 1 to 46 bytes are never cleared or counted toward keep
_TINY_RESULT_BYTES: Final = 46
ToolNames = Literal["all"] | frozenset[str]
class _ClearAtLeast(BaseModel):
model_config = ConfigDict(frozen=True)
type: Literal["input_tokens"]
value: Annotated[StrictInt, Field(ge=0)]
class _ClearToolUsesFields(BaseModel):
model_config = ConfigDict(frozen=True)
clear_at_least: _ClearAtLeast | None = None
exclude_tools: tuple[str, ...] | None = None
clear_tool_inputs: bool | tuple[str, ...] | None = None
_BLOCKS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
# api.anthropic.com, measured 2026-10-19: its wording for the two shapes pydantic words differently
_API_MESSAGES: Final = MappingProxyType(
{
"tuple_type": "Input should be a valid array",
"model_type": "Input does not match the expected shape.",
}
)
class ClearToolUsesEditSpec(TypedDict, total=False):
"""The ``clear_tool_uses_20250919`` entry of a ``context_management`` spec."""
@ -27,36 +74,125 @@ class ClearToolUsesEditSpec(TypedDict, total=False):
clear_tool_inputs: ReadOnly[object]
def _count_tool_uses(messages: list[dict[str, object]]) -> int:
"""Return the number of tool_use content blocks across all messages.
Only counts blocks with a string ``id`` to stay consistent with
:func:`_collect_tool_use_ids_in_order`, which is the source of truth for
which blocks are clearable.
"""
count = 0
for msg in messages:
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
if isinstance(block.get("id"), str):
count += 1
return count
def _parse(adapter: TypeAdapter[T], value: object) -> T | None:
try:
return adapter.validate_python(value)
except ValidationError:
return None
def _collect_tool_use_ids_in_order(messages: list[dict[str, object]]) -> list[str]:
"""Return tool_use ids in the chronological order they appear in messages."""
ids: Final[list[str]] = []
for msg in messages:
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
block_id = block.get("id")
if isinstance(block_id, str):
ids.append(block_id)
return ids
@dataclass(frozen=True, slots=True)
class CheckedEdit:
edit: dict[str, object]
error: AnthropicContextManagementError | None
def check_clear_tool_uses_spec(edit_spec: dict[str, object], index: int) -> CheckedEdit:
"""``edits[index]`` without its malformed fields, with the API's 400 for the first one, or None when there is none."""
try:
_ClearToolUsesFields.model_validate(edit_spec)
except ValidationError as error:
details: Final = error.errors(include_url=False)
malformed: Final = frozenset(detail["loc"][0] for detail in details)
first: Final = details[0]
location: Final = ".".join(map(str, ("edits", index, CLEAR_TOOL_USES_EDIT_TYPE, *first["loc"])))
return CheckedEdit(
edit={key: value for key, value in edit_spec.items() if key not in malformed},
error=AnthropicContextManagementError(
status_code=400,
message=f"context_management.{location}: {_API_MESSAGES.get(first['type'], first['msg'])}",
),
)
return CheckedEdit(edit=edit_spec, error=None)
def _input_tools(clear_tool_inputs: bool | tuple[str, ...] | None) -> ToolNames:
return "all" if clear_tool_inputs is True else frozenset(clear_tool_inputs or ())
def _all_blocks(messages: Sequence[Mapping[str, object]]) -> tuple[Mapping[str, object], ...]:
return tuple(chain.from_iterable(_parse(_BLOCKS, message.get("content")) or () for message in messages))
def _tool_use_ref(block: Mapping[str, object]) -> tuple[str, str | None] | None:
block_id: Final = block.get("id")
name: Final = block.get("name")
if block.get("type") != "tool_use" or not isinstance(block_id, str):
return None
return block_id, name if isinstance(name, str) else None
def _tool_uses_in_order(blocks: Sequence[Mapping[str, object]]) -> tuple[tuple[str, str | None], ...]:
refs: Final = (_tool_use_ref(block) for block in blocks)
return tuple(ref for ref in refs if ref is not None)
def _block_id(block: Mapping[str, object], block_type: str, id_key: str) -> str | None:
block_id: Final = block.get(id_key)
return block_id if block.get("type") == block_type and isinstance(block_id, str) else None
def _blocks_by_id(
blocks: Sequence[Mapping[str, object]], block_type: str, id_key: str
) -> Mapping[str, Mapping[str, object]]:
pairs: Final = ((_block_id(block, block_type, id_key), block) for block in blocks)
return {block_id: block for block_id, block in pairs if block_id is not None}
def _utf8_size(text: str) -> int:
return len(text.encode("utf-8", "surrogatepass"))
def _text_bytes(content: object) -> int | None:
"""Return the UTF-8 size of a text-only result, or None when it holds anything but text."""
if isinstance(content, str):
return _utf8_size(content)
parts: Final = _parse(_BLOCKS, content) or ()
texts: Final = tuple(part.get("text") for part in parts)
if any(part.get("type") != "text" or not isinstance(text, str) for part, text in zip(parts, texts)):
return None
return sum(_utf8_size(text) for text in texts if isinstance(text, str))
def _is_tiny(content: object) -> bool:
size: Final = _text_bytes(content)
return size is not None and 0 < size <= _TINY_RESULT_BYTES
def _tiny_result_ids(results: Mapping[str, Mapping[str, object]]) -> frozenset[str]:
"""Return ids whose result is 1 to 46 bytes of text; the API neither counts nor clears those uses."""
return frozenset(tool_use_id for tool_use_id, block in results.items() if _is_tiny(block.get("content")))
def _clears_input(name: str | None, input_tools: ToolNames) -> bool:
return input_tools == "all" or (name is not None and name in input_tools)
def _cleared_result(block: Mapping[str, object]) -> dict[str, object]:
return {**block, "content": build_cleared_tool_result_content(block.get("content"))}
def _freed_tokens(model: str, result: Mapping[str, object], use: Mapping[str, object], clears_input: bool) -> int:
"""Tokens that clearing this use frees: its result, and its input when ``clear_tool_inputs`` covers it."""
before: Final = [result, use] if clears_input else [result]
after: Final = [_cleared_result(result), {**use, "input": {}}] if clears_input else [_cleared_result(result)]
counts: Final = tuple(
litellm.token_counter(model=model, messages=[{"role": "user", "content": blocks}]) for blocks in (before, after)
)
return max(counts[0] - counts[1], 0)
def _select_in_batches(candidates: Sequence[str], tokens: Mapping[str, int], clear_at_least: int) -> tuple[str, ...]:
"""Oldest first, close a batch once it holds ``clear_at_least`` tokens; drop the incomplete last batch."""
def step(state: tuple[int, int], item: tuple[int, str]) -> tuple[int, int]:
batch_end, batch_tokens = state
index, tool_use_id = item
total: Final = batch_tokens + tokens[tool_use_id]
return (index + 1, 0) if total >= clear_at_least else (batch_end, total)
batch_end, _ = reduce(step, enumerate(candidates), (0, 0))
return tuple(candidates[:batch_end])
def _trigger_met(
@ -64,6 +200,7 @@ def _trigger_met(
model: str,
messages: list[dict[str, object]],
tools: list[dict[str, object]] | None,
counted_tool_uses: int,
) -> tuple[bool, int | None]:
"""Return (trigger_met, input_tokens if counted for reuse)."""
trigger_type: Final = trigger.get("type", "input_tokens")
@ -72,7 +209,7 @@ def _trigger_met(
if trigger_type == "tool_uses":
if not isinstance(threshold, int):
return False, None
return _count_tool_uses(messages) > threshold, None
return counted_tool_uses > threshold, None
if not isinstance(threshold, int):
threshold = DEFAULT_INPUT_TOKENS_TRIGGER
@ -132,11 +269,7 @@ def _clear_tool_results(
and block.get("type") == "tool_result"
and block.get("tool_use_id") in ids_to_clear
):
new_block = {
**block,
"content": build_cleared_tool_result_content(block.get("content")),
}
new_blocks.append(new_block)
new_blocks.append(_cleared_result(block))
mutated = True
cleared += 1
else:
@ -150,6 +283,23 @@ def _clear_tool_results(
return new_messages, cleared
def _without_input(block: Mapping[str, object], ids_to_clear: Set[str], tool_names: ToolNames) -> Mapping[str, object]:
match _tool_use_ref(block):
case (block_id, name) if block_id in ids_to_clear and _clears_input(name, tool_names):
return {**block, "input": dict[str, object]()}
case _:
return block
def _message_without_inputs(
message: dict[str, object], ids_to_clear: Set[str], tool_names: ToolNames
) -> dict[str, object]: # mutable-ok: the editor contract hands messages back as list[dict]
blocks: Final = _parse(_BLOCKS, message.get("content"))
if blocks is None:
return message
return {**message, "content": [_without_input(block, ids_to_clear, tool_names) for block in blocks]}
def apply_clear_tool_uses_20250919(
*,
model: str,
@ -158,15 +308,11 @@ def apply_clear_tool_uses_20250919(
system: str | list[dict[str, object]] | None,
edit_spec: ClearToolUsesEditSpec,
) -> tuple[list[dict[str, object]], AppliedEdit | None]:
"""Apply clear_tool_uses; return (messages, AppliedEdit or None)."""
ignored_knobs = [knob for knob in ("clear_at_least", "exclude_tools", "clear_tool_inputs") if knob in edit_spec]
for ignored_knob in ignored_knobs:
verbose_logger.warning(
"context_management polyfill: ignoring '%s' on %s "
"(supported only on Anthropic-family forwarding path in v0)",
ignored_knob,
CLEAR_TOOL_USES_EDIT_TYPE,
)
"""Apply clear_tool_uses to an edit ``check_clear_tool_uses_spec`` returned; return (messages, AppliedEdit or None)."""
fields: Final = _ClearToolUsesFields.model_validate(edit_spec)
clear_at_least: Final = 0 if fields.clear_at_least is None else fields.clear_at_least.value
exclude_tools: Final = frozenset(fields.exclude_tools or ())
input_tools: Final = _input_tools(fields.clear_tool_inputs)
trigger: Final[dict[str, object]] = edit_spec.get("trigger") or {
"type": "input_tokens",
@ -177,37 +323,56 @@ def apply_clear_tool_uses_20250919(
"value": DEFAULT_KEEP_TOOL_USES,
}
met, tokens_before = _trigger_met(trigger, model, messages, tools)
blocks: Final = _all_blocks(messages)
results: Final = _blocks_by_id(blocks, "tool_result", "tool_use_id")
uses: Final = _blocks_by_id(blocks, "tool_use", "id")
tiny: Final = _tiny_result_ids(results)
clearable: Final = tuple(
(tool_use_id, name)
for tool_use_id, name in _tool_uses_in_order(blocks)
if name not in exclude_tools and (tool_use_id not in tiny or _clears_input(name, input_tools))
)
met, counted_tokens = _trigger_met(trigger, model, messages, tools, len(clearable))
if not met:
return messages, None
keep_count: Final = _resolve_keep_count(keep)
tool_use_ids: Final = _collect_tool_use_ids_in_order(messages)
if len(tool_use_ids) <= keep_count:
if len(clearable) <= keep_count:
return messages, None
ids_to_clear: Final = set(tool_use_ids[: len(tool_use_ids) - keep_count])
# Never clear the latest completed tool_result (reply context).
last_completed_id: Final = _last_completed_tool_use_id(messages)
if last_completed_id is not None:
ids_to_clear.discard(last_completed_id)
candidates: Final = tuple(
(tool_use_id, name)
for tool_use_id, name in clearable[: len(clearable) - keep_count]
if tool_use_id in results and tool_use_id != last_completed_id
)
candidate_ids: Final = tuple(tool_use_id for tool_use_id, _ in candidates)
tokens: Final = {
tool_use_id: _freed_tokens(model, results[tool_use_id], uses[tool_use_id], _clears_input(name, input_tools))
for tool_use_id, name in (candidates if clear_at_least > 0 else ())
}
ids_to_clear: Final = frozenset(
_select_in_batches(candidate_ids, tokens, clear_at_least) if clear_at_least > 0 else candidate_ids
)
edited, cleared_count = _clear_tool_results(messages, ids_to_clear)
edited, cleared_count = _clear_tool_results(messages, set(ids_to_clear))
verbose_logger.debug("context_management polyfill: edited: %s", edited)
if cleared_count == 0:
return messages, None
final_messages: Final = (
[_message_without_inputs(message, ids_to_clear, input_tools) for message in edited] if input_tools else edited
)
if tokens_before is None:
tokens_before = litellm.token_counter(model=model, messages=messages, tools=cast(Any, tools))
tokens_after: Final = litellm.token_counter(model=model, messages=edited, tools=cast(Any, tools))
tokens_before: Final = (
counted_tokens
if counted_tokens is not None
else litellm.token_counter(model=model, messages=messages, tools=cast(Any, tools))
)
tokens_after: Final = litellm.token_counter(model=model, messages=final_messages, tools=cast(Any, tools))
cleared_input_tokens: Final = max(tokens_before - tokens_after, 0)
applied: Final[AppliedEdit] = {
return final_messages, {
"type": CLEAR_TOOL_USES_EDIT_TYPE,
"cleared_tool_uses": cleared_count,
"cleared_input_tokens": cleared_input_tokens,
}
if ignored_knobs:
applied["warnings"] = [f"{knob}_ignored" for knob in ignored_knobs]
return edited, applied

View file

@ -1,11 +1,18 @@
from collections.abc import Iterator, Mapping, Sequence
from itertools import chain
import json
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from functools import partial
from itertools import chain, groupby
from operator import itemgetter
from types import MappingProxyType
from typing import Final
from typing import Final, TypeAlias
from pydantic import JsonValue, TypeAdapter, ValidationError
from typing_extensions import assert_never
import litellm
from litellm.litellm_core_utils.core_helpers import as_str_mapping, drops_unsupported_params
from litellm.litellm_core_utils.prompt_templates.factory import sanitize_anthropic_tool_use_id
from litellm.llms.anthropic.pass_through.messages.utils import normalize_native_messages
from litellm.types.utils import TokenCountMessagesFormat
@ -104,3 +111,245 @@ def unconvertible_blocks_error(
)
case _:
assert_never(messages_format)
_OBJECTS: Final = TypeAdapter(tuple[object, ...])
def _list_items(value: object) -> tuple[object, ...]:
return _OBJECTS.validate_python(value) if isinstance(value, list) else ()
@dataclass(frozen=True, slots=True)
class _KeptPart:
block: object
scanned_text: str
_KeptParts: TypeAlias = Mapping[int, _KeptPart]
_NO_KEPT_PARTS: Final[_KeptParts] = MappingProxyType({})
def _scannable_text(text: object) -> str:
return json.dumps(text, default=str, ensure_ascii=False)
def _non_str_text(block: object) -> _KeptPart | None:
match as_str_mapping(block):
case {"type": "text", "text": str()}:
return None
case {"type": "text", "text": text}:
return _KeptPart(block=block, scanned_text=_scannable_text(text))
case _:
return None
def tool_result_text_part(block: object) -> str | None:
match block:
case {"type": "text", "text": str(text)}:
return text
case {"type": "text", "text": object()}:
return None
case {"type": "text"}:
return ""
case _:
return None
def tool_result_text_parts(tool_result: Mapping[str, object]) -> tuple[str, ...]:
parts: Final = map(tool_result_text_part, _list_items(tool_result.get("content")))
return tuple(part for part in parts if part is not None)
def _user_content_blocks(message: object) -> tuple[object, ...]:
match as_str_mapping(message):
case {"role": "user", "content": content}:
return _list_items(content)
case _:
return ()
def _with_user_blocks(message: object, transform: Callable[[object], object]) -> object:
match as_str_mapping(message):
case {"role": "user", "content": list()} as user_message:
return {**user_message, "content": [transform(block) for block in _user_content_blocks(user_message)]}
case _:
return message
def _non_string_text_errors(label: str, content: object) -> Iterator[str]:
return (
f"{label} content.{index}.text: Input should be a valid string"
for index, text_block in enumerate(_list_items(content))
if _non_str_text(text_block) is not None
)
def _tool_result_text_errors(block: object) -> Iterator[str]:
match block:
case {"type": "tool_result", "tool_use_id": object() as tool_use_id, "content": object() as content}:
return _non_string_text_errors(f"tool_result {tool_use_id}", content)
case {"type": "tool_result", "content": object() as content}:
return _non_string_text_errors("tool_result", content)
case _:
return iter(())
def malformed_tool_result_text_error(messages: Iterable[object]) -> str | None:
blocks: Final = chain.from_iterable(map(_user_content_blocks, messages))
return next(chain.from_iterable(map(_tool_result_text_errors, blocks)), None)
def malformed_tool_result_count_error(
messages: Iterable[Mapping[str, object]] | None,
messages_format: TokenCountMessagesFormat,
deployment: Mapping[str, object] | None,
) -> str | None:
if messages_format != "anthropic" or deployment_drops_params(deployment):
return None
return malformed_tool_result_text_error(messages or ())
def _scannable_part(part: object) -> object:
match as_str_mapping(part):
case {"type": "text", "text": str()}:
return part
case {"type": "text", "text": text} as text_block:
return {**text_block, "text": _scannable_text(text)}
case _:
return part
def _scannable_block(block: object) -> object:
match as_str_mapping(block):
case {"type": "tool_result", "content": list()} as tool_result:
return {
**tool_result,
"content": [_scannable_part(part) for part in _list_items(tool_result.get("content"))],
}
case _:
return block
def _scannable_messages(messages: object) -> object:
items: Final = _list_items(messages)
return [_with_user_blocks(message, _scannable_block) for message in items] if items else messages
def scannable_tool_result_request(request: object) -> object:
match as_str_mapping(request):
case {"messages": messages} as request_fields:
return {**request_fields, "messages": _scannable_messages(messages)}
case None:
return request
case request_fields:
return {**request_fields}
def _non_string_text_parts(content: object) -> _KeptParts:
return MappingProxyType(
{index: part for index, block in enumerate(_list_items(content)) if (part := _non_str_text(block)) is not None}
)
def _tool_result_non_string_texts(block: object) -> tuple[tuple[str, _KeptParts], ...]:
match as_str_mapping(block):
case {"type": "tool_result", "tool_use_id": str(tool_use_id)} as tool_result:
return ((sanitize_anthropic_tool_use_id(tool_use_id), _non_string_text_parts(tool_result.get("content"))),)
case _:
return ()
def _content_blocks(content: object) -> tuple[object, ...]:
match content:
case str() if content:
return ({"type": "text", "text": content},)
case _:
return _list_items(content)
def _restored_part(block: object, kept: _KeptPart | None) -> object:
return kept.block if kept is not None and tool_result_text_part(block) == kept.scanned_text else block
def _with_kept_parts(block: object, tool_result: Mapping[str, object], parts: _KeptParts) -> object:
if not parts:
return block
blocks: Final = _content_blocks(tool_result.get("content"))
return {**tool_result, "content": [_restored_part(part, parts.get(index)) for index, part in enumerate(blocks)]}
def _restored_block(block: object, kept: Mapping[str, Iterator[_KeptParts]]) -> object:
match as_str_mapping(block):
case {"type": "tool_result", "tool_use_id": str(tool_use_id)} as tool_result if (
sanitize_anthropic_tool_use_id(tool_use_id) in kept
):
return _with_kept_parts(
block, tool_result, next(kept[sanitize_anthropic_tool_use_id(tool_use_id)], _NO_KEPT_PARTS)
)
case _:
return block
def keep_non_string_tool_result_texts(rewritten: Sequence[object], original: object) -> Sequence[object]:
blocks: Final = chain.from_iterable(map(_user_content_blocks, _list_items(original)))
by_id: Final = tuple(sorted(chain.from_iterable(map(_tool_result_non_string_texts, blocks)), key=itemgetter(0)))
if not any(parts for _, parts in by_id):
return rewritten
kept_in_order: Final = MappingProxyType(
{
tool_use_id: iter(tuple(map(itemgetter(1), group)))
for tool_use_id, group in groupby(by_id, key=itemgetter(0))
}
)
return [_with_user_blocks(message, partial(_restored_block, kept=kept_in_order)) for message in rewritten]
def _without_non_string_text(block: object) -> object:
match as_str_mapping(block):
case {"type": "tool_result", "content": list()} as tool_result:
parts: Final = _list_items(tool_result.get("content"))
return {**tool_result, "content": [part for part in parts if _non_str_text(part) is None]}
case _:
return block
def _without_non_string_texts(
message: dict[str, object], # mutable-ok: AnthropicMessagesRequest.messages is typed list[dict]
) -> dict[str, object]: # mutable-ok: AnthropicMessagesRequest.messages is typed list[dict]
match message:
case {"role": "user", "content": list()}:
return {**message, "content": [_without_non_string_text(block) for block in _user_content_blocks(message)]}
case _:
return message
def drop_non_string_tool_result_texts(
messages: Sequence[dict[str, object]], # mutable-ok: AnthropicMessagesRequest.messages is typed list[dict]
drop_params: object,
) -> Sequence[dict[str, object]]: # mutable-ok: AnthropicMessagesRequest.messages is typed list[dict]
if not drops_unsupported_params(drop_params) or malformed_tool_result_text_error(messages) is None:
return messages
return [_without_non_string_texts(message) for message in messages]
def deployment_drops_params(deployment: Mapping[str, object] | None) -> bool:
match deployment:
case {"litellm_params": {"drop_params": object() as drop_params}}:
return drops_unsupported_params(drop_params)
case _:
return drops_unsupported_params(None)
def reject_malformed_tool_result_text(
messages: Iterable[Mapping[str, object]], model: str, forwarded_kwargs: Mapping[str, object]
) -> None:
if drops_unsupported_params(forwarded_kwargs.get("drop_params")):
return
error: Final = malformed_tool_result_text_error(messages)
if error is None:
return
provider: Final = forwarded_kwargs.get("custom_llm_provider")
raise litellm.BadRequestError(
message=error, model=model, llm_provider=provider if isinstance(provider, str) else ""
)

View file

@ -15,10 +15,13 @@ from pydantic import TypeAdapter
import litellm
from litellm.anthropic_beta_headers_manager import aload_beta_headers_config
from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook
from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.litellm_core_utils.exception_mapping_utils import exception_type
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.anthropic.common_utils import (
strip_provider_specific_fields_from_anthropic_messages,
tools_marked_for_a_tool_config_point,
)
from litellm.llms.base_llm.anthropic_messages.transformation import (
BaseAnthropicMessagesConfig,
@ -26,6 +29,15 @@ from litellm.llms.base_llm.anthropic_messages.transformation import (
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.responses.mcp.tool_precheck import may_name_litellm_mcp_gateway
from litellm.router_utils.reasoning_effort_capability import (
RequestedReasoningEffort,
deployment_reasoning_effort_levels,
keeps_effort,
model_group_from_metadata,
resolve_deployment_reasoning_effort,
with_adaptive_thinking_as_effort,
)
from litellm.types.llms.anthropic import AllAnthropicToolsValues
from litellm.types.llms.anthropic_messages.anthropic_request import AnthropicMetadata
from litellm.types.llms.anthropic_messages.anthropic_response import (
@ -144,6 +156,48 @@ def _deployment_passes_through_anthropic_messages(model_info: object) -> bool:
return isinstance(supported_endpoints, (list, tuple)) and "/v1/messages" in supported_endpoints
def _with_output_config(params: Mapping[str, object], output_config: RequestedReasoningEffort) -> Mapping[str, object]:
rest: Final = {key: value for key, value in params.items() if key != "output_config"}
return rest if output_config is None else {**rest, "output_config": output_config}
def _with_deployment_output_effort(
params: Mapping[str, object],
*,
model: str,
custom_llm_provider: str,
kwargs: Mapping[str, object],
drop_params: bool,
) -> Mapping[str, object]:
deployment_model_info: Final = kwargs.get("model_info")
def resolve(requested: RequestedReasoningEffort) -> RequestedReasoningEffort:
return resolve_deployment_reasoning_effort(
requested,
model=model,
custom_llm_provider=custom_llm_provider,
deployment_model_info=deployment_model_info,
model_group=model_group_from_metadata(kwargs.get("litellm_metadata"), kwargs.get("metadata")),
drop_params=drop_params,
).value
def translate(source: Mapping[str, object]) -> Mapping[str, object]:
return with_adaptive_thinking_as_effort(
source, model=model, custom_llm_provider=custom_llm_provider, drop_params=drop_params
)
requested_output: Final = as_str_mapping(params.get("output_config"))
caller_output: Final = None if requested_output is None else resolve(requested_output)
caller_params: Final = params if requested_output is None else _with_output_config(params, caller_output)
if deployment_reasoning_effort_levels(deployment_model_info) is None:
return caller_params
if requested_output is not None and keeps_effort(requested_output, caller_output):
return _with_output_config(translate(params), caller_output)
translated: Final = translate(caller_params)
budget_output: Final = as_str_mapping(translated.get("output_config"))
return translated if budget_output is None else _with_output_config(translated, resolve(budget_output))
def _deployment_supports_cache_control_ttl(model_info: object) -> bool:
return isinstance(model_info, dict) and model_info.get("cache_control_ttl") is True
@ -601,38 +655,34 @@ def anthropic_messages_handler(
# downstream path (native passthrough and both bridges) gets real tools rather than a
# reference the provider cannot resolve. Popped from kwargs so it never reaches the provider.
skip_mcp_handler: Final = kwargs.pop("_skip_mcp_handler", False)
if not skip_mcp_handler and tools:
if not skip_mcp_handler and tools and may_name_litellm_mcp_gateway(tools): # pyright: ignore[reportUnknownArgumentType] # untyped tools list
from litellm.llms.anthropic.pass_through.messages.mcp_handler import (
anthropic_messages_with_mcp,
)
from litellm.responses.mcp.litellm_proxy_mcp_handler import (
LiteLLM_Proxy_MCP_Handler,
)
if LiteLLM_Proxy_MCP_Handler.should_use_litellm_mcp_gateway(tools=tools):
return anthropic_messages_with_mcp(
max_tokens=max_tokens,
messages=messages,
model=model,
metadata=metadata,
stop_sequences=stop_sequences,
stream=stream,
system=cast( # cast-ok: anthropic accepts list system blocks; handler signature is narrower
"str | None", system
),
temperature=temperature,
thinking=thinking,
tool_choice=tool_choice,
tools=tools,
top_k=top_k,
top_p=top_p,
container=container,
api_key=api_key,
api_base=api_base,
client=client,
custom_llm_provider=custom_llm_provider,
**kwargs,
)
return anthropic_messages_with_mcp(
max_tokens=max_tokens,
messages=messages,
model=model,
metadata=metadata,
stop_sequences=stop_sequences,
stream=stream,
system=cast( # cast-ok: anthropic accepts list system blocks; handler signature is narrower
"str | None", system
),
temperature=temperature,
thinking=thinking,
tool_choice=tool_choice,
tools=tools,
top_k=top_k,
top_p=top_p,
container=container,
api_key=api_key,
api_base=api_base,
client=client,
custom_llm_provider=custom_llm_provider,
**kwargs,
)
anthropic_messages_provider_config: Final = _native_messages_config(
model, custom_llm_provider, kwargs.get("model_info"), api_base
@ -701,7 +751,13 @@ def anthropic_messages_handler(
f"custom_llm_provider is required for Anthropic messages, passed in model={model}, custom_llm_provider={custom_llm_provider}"
)
local_vars.update(kwargs)
request_kwargs: Final = cast(dict[str, object], kwargs) # cast-ok: **kwargs of the handler is untyped
marked_tools: Final = tools_marked_for_a_tool_config_point(
local_vars.get("tools"), request_kwargs.pop("cache_control_injection_points", None)
)
if marked_tools is not None:
AnthropicCacheControlHook.record_gateway_injection(request_kwargs, 1)
local_vars.update({**request_kwargs, **({"tools": list(marked_tools)} if marked_tools is not None else {})})
anthropic_messages_optional_request_params: Final = (
AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
params=local_vars,
@ -727,7 +783,15 @@ def anthropic_messages_handler(
),
),
anthropic_messages_provider_config=anthropic_messages_provider_config,
anthropic_messages_optional_request_params=dict(anthropic_messages_optional_request_params),
anthropic_messages_optional_request_params=dict(
_with_deployment_output_effort(
anthropic_messages_optional_request_params,
model=model,
custom_llm_provider=custom_llm_provider,
kwargs=cast(Mapping[str, object], kwargs), # cast-ok: router kwargs arrive untyped
drop_params=litellm.drop_params is True or litellm_params.get("drop_params") is True,
)
),
_is_async=is_async,
client=client,
custom_llm_provider=custom_llm_provider,

View file

@ -27,6 +27,7 @@ from litellm.types.llms.anthropic import ANTHROPIC_ADVISOR_TOOL_TYPE
from litellm.types.llms.anthropic_messages.anthropic_response import (
AnthropicMessagesResponse,
)
from litellm.types.utils import API_KEY_SECRET_REFERENCE_REJECTION, OIDC_SECRET_REFERENCE_PREFIX
if TYPE_CHECKING:
from litellm.router import Router
@ -226,6 +227,8 @@ def _resolve_advisor_credentials(advisor_tool: dict) -> tuple[str | None, str |
return None, None
api_key: Final[str | None] = advisor_tool.get("api_key")
api_base: Final[str | None] = advisor_tool.get("api_base")
if isinstance(api_key, str) and api_key.startswith(OIDC_SECRET_REFERENCE_PREFIX):
raise litellm.BadRequestError(message=API_KEY_SECRET_REFERENCE_REJECTION, model=None, llm_provider=None)
if api_base is None:
return api_key, None
if not api_key:

View file

@ -1,5 +1,6 @@
import re
from collections.abc import AsyncIterator, Mapping, Sequence
from datetime import datetime
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, cast
@ -153,6 +154,8 @@ class CachedAnthropicMessagesStreamIterator(BaseAnthropicMessagesStreamingIterat
await self._handle_streaming_logging(chunks)
raise StopAsyncIteration
chunk: Final = self.chunks[self.current_index]
if self.completion_start_time is None:
self.completion_start_time = datetime.now()
self.current_index += 1
return chunk

View file

@ -506,7 +506,7 @@ class BaseAnthropicMessagesStreamingIterator:
):
self.litellm_logging_obj = litellm_logging_obj
self.request_body = request_body
self.start_time = datetime.now()
self.start_time = litellm_logging_obj.start_time
self.completion_start_time: datetime | None = None
async def _handle_streaming_logging(self, collected_chunks: list[bytes], *, stream_teardown: bool = False):

View file

@ -7,7 +7,9 @@ import litellm
from litellm._logging import verbose_logger
from litellm.anthropic_beta_headers_manager import get_unsupported_headers, is_beta_header_supported_by_bundled_config
from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.anthropic.pass_through.convertible_blocks import drop_non_string_tool_result_texts
from litellm.llms.anthropic.pass_through.safeguards import proxy_serves_safeguards
from litellm.llms.base_llm.anthropic_messages.transformation import (
BaseAnthropicMessagesConfig,
@ -626,6 +628,14 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
):
anthropic_messages_optional_request_params.pop("safeguards") # pyright: ignore[reportUnknownMemberType] # the request params arrive as a bare dict
output_format: Final[object] = anthropic_messages_optional_request_params.get("output_format") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the request params arrive as a bare dict
if (
output_format is not None
and not isinstance(output_format, Mapping)
and drops_unsupported_params(litellm_params.drop_params)
):
anthropic_messages_optional_request_params.pop("output_format") # pyright: ignore[reportUnknownMemberType] # the request params arrive as a bare dict
self._translate_reasoning_effort_to_anthropic(
model=model,
optional_params=anthropic_messages_optional_request_params,
@ -692,7 +702,9 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
messages = strip_advisor_blocks_from_messages(messages)
anthropic_messages_request: Final[AnthropicMessagesRequest] = AnthropicMessagesRequest(
messages=strip_encrypted_reasoning_blocks_from_anthropic_messages(messages),
messages=strip_encrypted_reasoning_blocks_from_anthropic_messages(
drop_non_string_tool_result_texts(messages, getattr(litellm_params, "drop_params", None))
),
max_tokens=max_tokens,
model=model,
**anthropic_messages_optional_request_params,

View file

@ -14,7 +14,7 @@ from litellm.integrations.anthropic_cache_control_hook import (
AnthropicCacheControlHook,
supports_openai_prompt_cache_breakpoint,
)
from litellm.litellm_core_utils.core_helpers import normalize_drop_params
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.prompt_templates.common_utils import cache_control_as_prompt_cache_breakpoint
from litellm.responses.utils import auto_reasoning_summary
from litellm.types.integrations.custom_logger import CONVERTED_STREAM_KEYS
@ -32,6 +32,7 @@ from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.utils import ProviderConfigManager
from ..adapters.handler import request_safeguards_evaluator
from ..convertible_blocks import reject_malformed_tool_result_text
from ..safeguards import with_safeguard_results
from ..utils import litellm_logging_obj_from_kwargs, local_model_name
from .streaming_iterator import AnthropicResponsesStreamWrapper
@ -96,7 +97,7 @@ def _prompt_cache_block_rewrite(model: str, forwarded_kwargs: Mapping[str, objec
return cache_control_as_prompt_cache_breakpoint
if _model_takes_prompt_cache_breakpoint(model, forwarded_kwargs):
return None
if normalize_drop_params(forwarded_kwargs.get("drop_params")) is True or litellm.drop_params is True:
if drops_unsupported_params(forwarded_kwargs.get("drop_params")):
return _without_prompt_cache_breakpoint
return None
@ -142,6 +143,7 @@ def _build_responses_kwargs(
Build the kwargs dict to pass directly to litellm.responses() / litellm.aresponses().
"""
forwarded_kwargs: Final = _forwarded_kwargs(extra_kwargs)
reject_malformed_tool_result_text(messages, model, forwarded_kwargs)
block_rewrite: Final = _prompt_cache_block_rewrite(model, forwarded_kwargs)
request_messages: Final[AnthropicRequestMessages] = (
[
@ -190,6 +192,7 @@ def _build_responses_kwargs(
model, forwarded_kwargs.get("custom_llm_provider")
),
auto_summary_level=auto_reasoning_summary(model, forwarded_kwargs.get("custom_llm_provider")),
drop_params=drops_unsupported_params(forwarded_kwargs.get("drop_params")),
)
# Normalize reasoning effort based on model capabilities
@ -207,6 +210,7 @@ def _build_responses_kwargs(
effort,
model=model,
custom_llm_provider=provider_hint if isinstance(provider_hint, str) else None,
deployment_model_info=forwarded_kwargs.get("model_info"),
)
if normalized != effort:
responses_kwargs["reasoning"] = {**reasoning, "effort": normalized}

View file

@ -2,7 +2,7 @@
## Translates OpenAI call to Anthropic `/v1/messages` format
import asyncio
from collections import deque
from collections.abc import AsyncIterator, Iterator, Mapping
from collections.abc import AsyncGenerator, Iterator, Mapping
from typing import TYPE_CHECKING, Any, Final
from pydantic import ConfigDict, field_validator
@ -21,7 +21,10 @@ from litellm.llms.anthropic.pass_through.messages.utils import (
)
from litellm.llms.anthropic.pass_through.safeguards import StreamedSafeguardResults
from litellm.llms.anthropic.pass_through.utils import anthropic_sse_frame
from litellm.responses.streaming_iterator import stream_error_status_and_message
from litellm.responses.streaming_iterator import (
partial_responses_incomplete_event_on_disconnect,
stream_error_status_and_message,
)
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage
from litellm.types.llms.base import LiteLLMBaseModel
@ -33,6 +36,7 @@ from .transformation import (
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
from litellm.llms.anthropic.pass_through.safeguards import SafeguardsEvaluator
from litellm.types.llms.openai import ResponseIncompleteEvent
class _UpstreamFailure(LiteLLMBaseModel):
@ -488,8 +492,38 @@ class AnthropicResponsesStreamWrapper:
raise StopAsyncIteration
async def async_anthropic_sse_wrapper(self) -> AsyncIterator[bytes]:
"""Yield SSE-encoded bytes for each Anthropic event chunk."""
def async_anthropic_sse_wrapper(self) -> "AnthropicResponsesSSEByteStream":
"""SSE-encoded bytes for each Anthropic event chunk."""
return AnthropicResponsesSSEByteStream(self)
async def _sse_frames(self) -> AsyncGenerator[bytes, None]:
safeguard_results: Final = StreamedSafeguardResults(self.safeguards_evaluator)
async for chunk in self:
yield anthropic_sse_frame(await safeguard_results.observe(chunk))
def partial_response_on_disconnect(self) -> "ResponseIncompleteEvent | None":
return partial_responses_incomplete_event_on_disconnect(self.responses_stream)
class AnthropicResponsesSSEByteStream:
"""
The byte stream the proxy relays for a default-route /v1/messages request.
Keeps the Responses API iterator reachable so a client disconnect can bill
the partial output the way native /v1/responses streams do.
"""
def __init__(self, wrapper: AnthropicResponsesStreamWrapper) -> None:
self._wrapper: Final = wrapper
self._frames: Final = wrapper._sse_frames() # pyright: ignore[reportPrivateUsage] # the wrapper's own byte stream
def __aiter__(self) -> "AnthropicResponsesSSEByteStream":
return self
async def __anext__(self) -> bytes:
return await self._frames.__anext__()
async def aclose(self) -> None:
await self._frames.aclose()
def partial_response_on_disconnect(self) -> "ResponseIncompleteEvent | None":
return self._wrapper.partial_response_on_disconnect()

View file

@ -18,8 +18,9 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
with_prompt_cache_breakpoint,
)
from litellm.litellm_core_utils.reasoning_effort_utils import (
reasoning_effort_from_thinking_budget,
reasoning_effort_from_thinking,
)
from litellm.llms.anthropic.pass_through.convertible_blocks import tool_result_text_parts
from litellm.llms.anthropic.pass_through.messages.utils import (
refusal_stop_details,
responses_output_refusal_text,
@ -371,10 +372,9 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
elif btype == "tool_result":
tool_use_id = block.get("tool_use_id", "")
inner = block.get("content")
tool_result_block = cast(Mapping[str, object], block) # cast-ok: TypedDict upcast
tool_breakpoint, covered_image_parts, later_image_parts = (
self._tool_result_images_by_breakpoint(
cast(Mapping[str, object], block) # cast-ok: TypedDict upcast
)
self._tool_result_images_by_breakpoint(tool_result_block)
)
document_candidates = (
tuple(
@ -391,10 +391,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
elif isinstance(inner, str):
output_text = inner
elif isinstance(inner, list):
parts = [
c.get("text", "") for c in inner if isinstance(c, dict) and c.get("type") == "text"
]
output_text = "\n".join(parts)
output_text = "\n".join(tool_result_text_parts(tool_result_block))
if covered_image_parts or later_image_parts:
output_text = (
f"{output_text}\n{TOOL_RESULT_IMAGE_PLACEHOLDER}"
@ -467,33 +464,32 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
return input_items
@staticmethod
def _translate_tool_to_responses_api(tool_dict: Mapping[str, object]) -> dict[str, object]:
tool_type: Final = tool_dict.get("type", "")
tool_name: Final = tool_dict.get("name", "")
# web_search tool
if (isinstance(tool_type, str) and tool_type.startswith("web_search")) or tool_name == "web_search":
return {"type": "web_search_preview"}
# Responses turns strict mode on when `strict` is omitted, silently rewriting
# `required` to every property. Anthropic tools are non-strict unless asked.
func_tool: Final[dict[str, object]] = {
"type": "function",
"name": tool_name,
"strict": bool(tool_dict.get("strict")),
}
if "description" in tool_dict:
func_tool["description"] = tool_dict["description"]
if "input_schema" in tool_dict:
func_tool["parameters"] = json_copy_or_original(tool_dict["input_schema"])
return func_tool
def translate_tools_to_responses_api(
self,
tools: list[AllAnthropicToolsValues],
) -> list[dict[str, object]]:
"""Convert Anthropic tool definitions to Responses API function tools."""
result: Final[list[dict[str, object]]] = []
for tool in tools:
tool_dict = cast(dict[str, Any], tool)
tool_type = tool_dict.get("type", "")
tool_name = tool_dict.get("name", "")
# web_search tool
if (isinstance(tool_type, str) and tool_type.startswith("web_search")) or tool_name == "web_search":
result.append({"type": "web_search_preview"})
continue
# Responses turns strict mode on when `strict` is omitted, silently rewriting
# `required` to every property. Anthropic tools are non-strict unless asked.
func_tool: dict[str, object] = {
"type": "function",
"name": tool_name,
"strict": bool(tool_dict.get("strict")),
}
if "description" in tool_dict:
func_tool["description"] = tool_dict["description"]
if "input_schema" in tool_dict:
func_tool["parameters"] = json_copy_or_original(tool_dict["input_schema"])
result.append(func_tool)
return result
return [self._translate_tool_to_responses_api(tool) for tool in tools]
@staticmethod
def translate_tool_choice_to_responses_api(
@ -545,6 +541,8 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
thinking: dict[str, object],
output_config: dict[str, object] | None = None,
auto_summary_level: str = "detailed",
*,
drop_params: bool = True,
) -> dict[str, object] | None:
"""
Convert Anthropic thinking param to Responses API reasoning param.
@ -565,9 +563,10 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
if isinstance(output_config, dict) and output_config.get("effort"):
effort = output_config["effort"]
elif thinking_type == "enabled":
raw_budget: Final = thinking.get("budget_tokens", 0)
budget_tokens: Final = int(raw_budget) if isinstance(raw_budget, (int, float)) else 0
effort = reasoning_effort_from_thinking_budget(budget_tokens)
budget_effort: Final = reasoning_effort_from_thinking(thinking, drop_params=drop_params)
if budget_effort is None:
return None
effort = budget_effort
else:
return None
@ -593,6 +592,8 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
anthropic_request: AnthropicMessagesRequest,
include_encrypted_reasoning: bool = True,
auto_summary_level: str = "detailed",
*,
drop_params: bool = True,
) -> dict[str, Any]:
"""
Translate a full Anthropic /v1/messages request dict to
@ -674,6 +675,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
thinking,
output_config=cast(dict[str, Any] | None, output_config),
auto_summary_level=auto_summary_level,
drop_params=drop_params,
)
if reasoning:
responses_kwargs["reasoning"] = reasoning

View file

@ -193,9 +193,13 @@ def normalize_reasoning_effort_value(
effort: str,
model: str,
custom_llm_provider: str | None = None,
deployment_model_info: object = None,
) -> str:
"""Lower a tier the deployment does not accept to the nearest one it does, leaving others alone.
A deployment whose own reasoning_effort_levels name a strength tier keeps the tier as sent, since
completion() and responses() round it onto those levels.
The accepted set is resolved by the same owner that answers ``/model_group/info``, so a level
the proxy advertises is a level this path forwards.
@ -210,7 +214,13 @@ def normalize_reasoning_effort_value(
if chain is None:
return effort
from litellm.router_utils.reasoning_effort_capability import resolve_supported_reasoning_efforts
from litellm.router_utils.reasoning_effort_capability import (
deployment_rounds_reasoning_effort,
resolve_supported_reasoning_efforts,
)
if deployment_rounds_reasoning_effort(deployment_model_info):
return effort
from litellm.utils import get_model_info
try:

View file

@ -1,5 +1,6 @@
"""Support for Azure OpenAI gpt-5 model family."""
from collections.abc import Collection
from typing import ClassVar, Final
import litellm
@ -86,6 +87,8 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
model: str,
drop_params: bool,
api_version: str = "",
*,
declared_reasoning_efforts: Collection[str] = (),
) -> dict:
return OpenAIGPT5Config.map_openai_params(
self,
@ -93,6 +96,7 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
optional_params=optional_params,
model=model,
drop_params=drop_params,
declared_reasoning_efforts=declared_reasoning_efforts,
)
def transform_request(

View file

@ -4,6 +4,7 @@ Azure AI Anthropic CountTokens API handler.
Uses httpx for HTTP requests with Azure authentication.
"""
from collections.abc import Mapping
from typing import Final
import httpx
@ -36,6 +37,7 @@ class AzureAIAnthropicCountTokensHandler(AzureAIAnthropicCountTokensConfig):
timeout: float | httpx.Timeout | None = None,
tools: list[dict[str, JsonValue]] | None = None,
system: object = None,
optional_params: Mapping[str, JsonValue] | None = None,
) -> dict[str, object]:
"""
Handle a CountTokens request using httpx with Azure authentication.
@ -66,6 +68,7 @@ class AzureAIAnthropicCountTokensHandler(AzureAIAnthropicCountTokensConfig):
messages=messages,
tools=tools,
system=system,
optional_params=optional_params,
)
verbose_logger.debug("Transformed request: %s", request_body)

View file

@ -3,12 +3,19 @@ Azure AI Anthropic Token Counter implementation using the CountTokens API.
"""
import os
from collections.abc import Mapping
from types import MappingProxyType
from typing import Any, Final
from pydantic import JsonValue
import litellm
from litellm._logging import verbose_logger
from litellm.llms.anthropic.count_tokens.transformation import (
AnthropicCountFields,
ChatCountTranslationError,
anthropic_count_fields,
)
from litellm.llms.azure_ai.anthropic.count_tokens.handler import (
AzureAIAnthropicCountTokensHandler,
)
@ -55,6 +62,8 @@ class AzureAIAnthropicTokenCounter(BaseTokenCounter):
messages_format: TokenCountMessagesFormat = "anthropic",
output_format: JsonValue = None,
context_management: CountContextManagement | None = None,
chat_params: Mapping[str, JsonValue] = MappingProxyType({}),
count_options: Mapping[str, JsonValue] = MappingProxyType({}),
) -> TokenCountResponse | None:
"""
Count tokens using Azure AI Anthropic's CountTokens API.
@ -74,6 +83,30 @@ class AzureAIAnthropicTokenCounter(BaseTokenCounter):
if not messages:
return None
from litellm.llms.azure_ai.anthropic.transformation import AzureAnthropicConfig
match translated := await anthropic_count_fields(
model_to_use, messages, system, tools, messages_format, AzureAnthropicConfig(), chat_params
):
case ChatCountTranslationError():
return translated.response(request_model, model_to_use, "azure_ai_anthropic_api")
case AnthropicCountFields():
return await self.count_tokens(
model_to_use=model_to_use,
messages=[dict(message) for message in translated.messages],
contents=contents,
deployment=deployment,
request_model=request_model,
tools=[dict(tool) for tool in translated.tools] if translated.tools is not None else None,
system=translated.system,
messages_format="anthropic",
output_format=output_format,
context_management=context_management,
count_options=translated.options,
)
case None:
pass
deployment = deployment or {}
litellm_params: Final = deployment.get("litellm_params", {})
@ -101,6 +134,7 @@ class AzureAIAnthropicTokenCounter(BaseTokenCounter):
litellm_params=litellm_params,
tools=tools,
system=system,
optional_params=count_options,
)
if result is not None:

View file

@ -1,7 +1,7 @@
import copy
import enum
import re
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Collection, Iterator
from typing import TYPE_CHECKING, Final, cast
import httpx
@ -104,6 +104,8 @@ class AzureAIStudioConfig(OpenAIConfig):
optional_params: dict[str, object], # mutable-ok: OpenAIConfig.map_openai_params signature
model: str,
drop_params: bool,
*,
declared_reasoning_efforts: Collection[str] = (),
) -> dict[str, object]: # mutable-ok: OpenAIConfig.map_openai_params signature
if not azureAIGPT5Config.is_model_gpt_5_model(model):
return super().map_openai_params(
@ -117,6 +119,7 @@ class AzureAIStudioConfig(OpenAIConfig):
optional_params=optional_params,
model=model,
drop_params=drop_params,
declared_reasoning_efforts=declared_reasoning_efforts,
)
def _supports_stop_reason(self, model: str) -> bool:

View file

@ -5,9 +5,11 @@ Utility functions for base LLM classes.
import copy
import itertools
import json
import re
from abc import ABC, abstractmethod
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Final
from openai.lib import _parsing, _pydantic # pyright: ignore[reportPrivateUsage] # SDK parser internals
@ -39,6 +41,7 @@ class BaseTokenCounter(ABC):
messages_format: TokenCountMessagesFormat = "openai",
output_format: JsonValue = None,
context_management: CountContextManagement | None = None,
chat_params: Mapping[str, JsonValue] = MappingProxyType({}),
) -> TokenCountResponse | None:
pass
@ -172,7 +175,7 @@ _convert_tool_response_to_message = convert_tool_response_to_message
@dataclass(frozen=True, slots=True)
class ResponseFormatSchema:
property_names: frozenset[str]
declared_names: frozenset[str]
required_names: frozenset[str] = frozenset()
allows_undeclared_keys: bool = False
@ -216,9 +219,10 @@ def response_format_schema(value: object) -> ResponseFormatSchema | None:
properties: Final = as_json_object(document.get("properties", {}))
if properties is None:
return None
required_names: Final = _names(document.get("required", ()))
return ResponseFormatSchema(
property_names=frozenset(properties),
required_names=_names(document.get("required", ())),
declared_names=frozenset(properties) | required_names,
required_names=required_names,
allows_undeclared_keys=_allows_undeclared_keys(document),
)
@ -244,8 +248,9 @@ def _json_object_or_encoded_object(value: JsonValue) -> Mapping[str, JsonValue]
def _label_keys(payload_object: Mapping[str, JsonValue], schema: ResponseFormatSchema) -> frozenset[str]:
if not schema.required_names:
return frozenset()
declared: Final = schema.property_names | schema.required_names
return frozenset(key for key, value in payload_object.items() if isinstance(value, str) and key not in declared)
return frozenset(
key for key, value in payload_object.items() if isinstance(value, str) and key not in schema.declared_names
)
def _envelope_layer(
@ -256,7 +261,7 @@ def _envelope_layer(
)
label_keys: Final = name_keys or _label_keys(payload_object, schema)
wrapper_keys: Final = tuple(key for key in payload_object if key not in label_keys)
if len(wrapper_keys) != 1 or wrapper_keys[0] in schema.property_names:
if len(wrapper_keys) != 1 or wrapper_keys[0] in schema.declared_names:
return None
wrapped: Final = payload_object[wrapper_keys[0]]
inner: Final = _json_object_or_encoded_object(wrapped) if name_keys else as_json_object(wrapped)
@ -275,15 +280,16 @@ def _envelope_layers(
def _schema_object_inside_envelope(
payload_object: Mapping[str, JsonValue], schema: ResponseFormatSchema, tool_name: str
) -> Mapping[str, JsonValue] | None:
layers: Final = _envelope_layers(payload_object, schema, tool_name)
candidates: Final = layers if schema.property_names else itertools.islice(layers, 1)
return next((layer.inner for layer in candidates if _holds_the_schema_object(layer, schema)), None)
walk: Final = _envelope_layers(payload_object, schema, tool_name)
layers: Final = tuple(walk if schema.declared_names else itertools.islice(walk, 1))
schema_object: Final = next((layer.inner for layer in layers if _shares_a_schema_key(layer.inner, schema)), None)
if schema_object is not None:
return schema_object
return next((layer.inner for layer in layers if layer.names_the_tool), None)
def _holds_the_schema_object(layer: _EnvelopeLayer, schema: ResponseFormatSchema) -> bool:
if layer.names_the_tool:
return True
return not schema.accepts_a_lone_undeclared_key and not schema.property_names.isdisjoint(layer.inner)
def _shares_a_schema_key(candidate: Mapping[str, JsonValue], schema: ResponseFormatSchema) -> bool:
return not schema.accepts_a_lone_undeclared_key and not schema.declared_names.isdisjoint(candidate)
def response_format_envelope_contents(payload: object, schema: ResponseFormatSchema, tool_name: str) -> object | None:
@ -293,7 +299,15 @@ def response_format_envelope_contents(payload: object, schema: ResponseFormatSch
schema_object: Final = _schema_object_inside_envelope(payload_object, schema, tool_name)
if schema_object is not None:
return schema_object
return None if "values" in schema.property_names else payload_object.get("values")
return None if "values" in schema.declared_names else payload_object.get("values")
_LONE_SURROGATE: Final = re.compile("[\ud800-\udfff]")
def response_format_answer_json(value: object) -> str:
text: Final = json.dumps(value, ensure_ascii=False)
return json.dumps(value) if _LONE_SURROGATE.search(text) else text
def unwrap_response_format_envelope(payload: object, schema: ResponseFormatSchema, tool_name: str) -> object:
@ -305,7 +319,7 @@ def _field_names(
blocks: tuple[Mapping[str, JsonValue], ...], key: str, schema: ResponseFormatSchema
) -> tuple[str, ...] | None:
values: Final = (block[key] for block in blocks)
names: Final = tuple(value for value in values if isinstance(value, str) and value in schema.property_names)
names: Final = tuple(value for value in values if isinstance(value, str) and value in schema.declared_names)
return names if len(frozenset(names)) == len(blocks) else None
@ -324,7 +338,7 @@ def _split_answer_fields(
blocks: tuple[Mapping[str, JsonValue], ...], schema: ResponseFormatSchema
) -> Mapping[str, JsonValue] | None:
keys: Final = frozenset(blocks[0])
if not keys.isdisjoint(schema.property_names) or any(frozenset(block) != keys for block in blocks):
if not keys.isdisjoint(schema.declared_names) or any(frozenset(block) != keys for block in blocks):
return None
named: Final = tuple((key, names) for key in keys if (names := _field_names(blocks, key, schema)) is not None)
if len(named) != 1:
@ -340,7 +354,7 @@ def _merged_partial_answers(
blocks: tuple[Mapping[str, JsonValue], ...], schema: ResponseFormatSchema
) -> Mapping[str, JsonValue] | None:
keys: Final = frozenset(itertools.chain.from_iterable(blocks))
if not keys <= schema.property_names or len(keys) != sum(len(block) for block in blocks):
if not keys <= schema.declared_names or len(keys) != sum(len(block) for block in blocks):
return None
return dict(itertools.chain.from_iterable(block.items() for block in blocks))
@ -358,14 +372,14 @@ def split_response_format_answer(
def partial_response_format_answer(encoded_block: str, schema: ResponseFormatSchema) -> Mapping[str, JsonValue] | None:
block: Final = _json_object_or_encoded_object(encoded_block)
return block if block is not None and frozenset(block) <= schema.property_names else None
return block if block is not None and frozenset(block) <= schema.declared_names else None
def names_a_response_format_property(encoded_block: str, schema: ResponseFormatSchema) -> bool:
block: Final = _json_object_or_encoded_object(encoded_block)
if block is None:
return False
return any(isinstance(value, str) and value in schema.property_names for value in block.values())
return any(isinstance(value, str) and value in schema.declared_names for value in block.values())
def _dict_to_response_format_helper(response_format: dict, ref_template: str | None = None) -> dict:

Some files were not shown because too many files have changed in this diff Show more