Merge remote-tracking branch 'origin/main' into litellm_autorouter_tool_turns_classifier

This commit is contained in:
mateo-berri 2026-10-28 13:19:47 -07:00
commit 3fab335add
114 changed files with 6168 additions and 778 deletions

View file

@ -19,7 +19,7 @@ jobs:
name: interactions-spec-canary
if: github.repository == 'BerriAI/litellm'
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 30
permissions:
contents: read
outputs:
@ -39,8 +39,11 @@ jobs:
with:
version: "0.10.9"
- name: Set up the Rust bridge
uses: ./.github/actions/rust-bridge
- name: Install dependencies
run: .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra proxy --no-install-project
run: .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra proxy
- name: Run the compliance tests against Google's live spec
id: compliance

View file

@ -132,7 +132,7 @@ jobs:
windows-sdk:
name: windows-sdk
runs-on: windows-2025
timeout-minutes: 10
timeout-minutes: 30
env:
LITELLM_LOCAL_MODEL_COST_MAP: "True"
defaults:
@ -154,8 +154,11 @@ jobs:
with:
version: "0.10.9"
- name: Set up the Rust bridge
uses: ./.github/actions/rust-bridge
- name: Install dependencies
run: .github/scripts/uv_sync_with_retries.sh --frozen --no-install-workspace --group dev --python 3.11
run: .github/scripts/uv_sync_with_retries.sh --frozen --group dev --python 3.11
- name: Run the Windows tests
run: uv run --no-sync python -m pytest --tb=short -v tests/windows_tests

View file

@ -1306,6 +1306,8 @@ jobs:
done
openai-sdk-compat:
name: OpenAI ${{ matrix.openai-version }} compatibility
needs: rust-bridge
if: ${{ !cancelled() }}
permissions:
contents: read
pull-requests: read
@ -1342,10 +1344,18 @@ jobs:
with:
version: "0.10.9"
- name: Set up the Rust bridge
if: steps.changes.outputs.decision != 'skip'
timeout-minutes: 5
uses: ./.github/actions/rust-bridge
with:
artifact: ${{ needs.rust-bridge.outputs.artifact }}
- name: Install dependencies
if: steps.changes.outputs.decision != 'skip'
run: |
.github/scripts/uv_sync_with_retries.sh --frozen --group dev --group proxy-dev --extra proxy --no-install-workspace
cp rust-bridge-dist/litellm/rust_bridge/_native.* litellm/rust_bridge/
uv pip install "openai==${OPENAI_VERSION:?}" --exclude-newer-package openai=2026-10-07T00:00:00Z
uv run --no-sync python -c 'import os, openai; assert openai.__version__ == os.environ["OPENAI_VERSION"], openai.__version__'

View file

@ -427,6 +427,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
| [Watsonx Text (`watsonx_text`)](https://docs.litellm.ai/docs/providers/watsonx) | ✅ | ✅ | ✅ | | | | | | | |
| [xAI (`xai`)](https://docs.litellm.ai/docs/providers/xai) | ✅ | ✅ | ✅ | | | | | | | |
| [Xinference (`xinference`)](https://docs.litellm.ai/docs/providers/xinference) | | | | ✅ | | | | | | |
| [ZeroGPU (`zerogpu`)](https://docs.litellm.ai/docs/providers/zerogpu) | ✅ | ✅ | ✅ | | | | | | | |
[**Read the Docs**](https://docs.litellm.ai/docs/)

View file

@ -153,7 +153,7 @@ RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \
find /app/.venv -type d -path "*/tornado/test" -delete && \
chmod -R a+rX /opt/prisma && \
python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths" && \
python -c "import litellm; from litellm.rust_bridge.loader import native_bridge_available; assert litellm.__file__ == '/app/litellm/__init__.py', litellm.__file__; assert native_bridge_available()"
python -c "import litellm, litellm.rust_bridge._native; assert litellm.__file__ == '/app/litellm/__init__.py', litellm.__file__"
USER nonroot

View file

@ -113,6 +113,7 @@ const OPENAI_COMPATIBLE_PROVIDERS: &[&str] = &[
"meta",
"cognition",
"scx-ai",
"zerogpu",
];
impl ExceptionFamily {

View file

@ -187,6 +187,7 @@ fn native_events_reach_python_with_levels_context_reentry_and_http_deduplication
locals
.set_item("http_warning", wrap_pyfunction!(http_warning, py).unwrap())
.unwrap();
locals.set_item("native", crate::native_module(py)).unwrap();
let importable = py
.eval(
c"__import__('importlib.util', fromlist=['util']).find_spec('dotenv') is not None",
@ -205,6 +206,7 @@ import asyncio
import logging
import sys
sys.path.insert(0, repo_root)
sys.modules['litellm.rust_bridge._native'] = native
import litellm
from litellm._logging import verbose_logger, session_id_var, trace_id_var

View file

@ -681,6 +681,7 @@ moonshot_models: Set = set()
publicai_models: Set = set()
darkbloom_models: Set = set()
openinfer_models: Final[Set[str]] = set()
zerogpu_models: Final[Set[str]] = set()
v0_models: Set = set()
morph_models: Set = set()
opencode_go_models: Final[set[str]] = set() # mutable-ok: populated when the shared model catalog loads
@ -969,6 +970,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje
darkbloom_models.add(key)
elif value.get("litellm_provider") == "openinfer":
openinfer_models.add(key)
elif value.get("litellm_provider") == "zerogpu":
zerogpu_models.add(key)
elif value.get("litellm_provider") == "v0":
v0_models.add(key)
elif value.get("litellm_provider") == "morph":
@ -1142,6 +1145,7 @@ model_list = list(
| publicai_models
| darkbloom_models
| openinfer_models
| zerogpu_models
| v0_models
| morph_models
| opencode_go_models
@ -1261,6 +1265,7 @@ def _build_models_by_provider() -> dict:
"publicai": publicai_models,
"darkbloom": darkbloom_models,
"openinfer": openinfer_models,
"zerogpu": zerogpu_models,
"v0": v0_models,
"morph": morph_models,
"opencode_go": opencode_go_models,
@ -1739,6 +1744,9 @@ if TYPE_CHECKING:
from .llms.lm_studio.decisions.transformation import (
LMStudioDecisionsConfig as LMStudioDecisionsConfig,
)
from .llms.ollama.decisions.transformation import (
OllamaDecisionsConfig as OllamaDecisionsConfig,
)
from .llms.laya.decisions.transformation import (
LayaDecisionsConfig as LayaDecisionsConfig,
)

View file

@ -171,6 +171,7 @@ LLM_CONFIG_NAMES: Final = (
"DatabricksDecisionsConfig",
"HostedVLLMDecisionsConfig",
"LMStudioDecisionsConfig",
"OllamaDecisionsConfig",
"LayaDecisionsConfig",
"BespokeDecisionsConfig",
"OpenAIDecisionsConfig",
@ -749,6 +750,7 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
"HostedVLLMDecisionsConfig",
),
"LMStudioDecisionsConfig": (".llms.lm_studio.decisions.transformation", "LMStudioDecisionsConfig"),
"OllamaDecisionsConfig": (".llms.ollama.decisions.transformation", "OllamaDecisionsConfig"),
"LayaDecisionsConfig": (".llms.laya.decisions.transformation", "LayaDecisionsConfig"),
"BespokeDecisionsConfig": (".llms.bespoke.decisions.transformation", "BespokeDecisionsConfig"),
"OpenAIDecisionsConfig": (".llms.openai.decisions.transformation", "OpenAIDecisionsConfig"),

View file

@ -3,7 +3,6 @@ Handler for transforming /chat/completions api requests to litellm.responses req
"""
import json
import os
from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Mapping, Sequence
from itertools import accumulate, chain
from types import MappingProxyType
@ -43,8 +42,8 @@ from litellm.responses.sse_output_recovery import (
)
from litellm.responses.utils import (
ResponsesAPIRequestUtils,
auto_reasoning_summary,
normalize_responses_api_stream_options,
opt_in_auto_reasoning_summary,
)
from litellm.types.llms.openai import (
ChatCompletionAnnotation,
@ -1745,19 +1744,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
**cast(Reasoning, reasoning_effort) # cast-ok: dict is forwarded verbatim to the provider
)
# Check if auto-summary is enabled via flag or environment variable
# Priority: litellm.reasoning_auto_summary flag > LITELLM_REASONING_AUTO_SUMMARY env var
auto_summary_enabled: Final = (
litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
)
summary: Final = opt_in_auto_reasoning_summary(model, custom_llm_provider)
return (
Reasoning(
effort=reasoning_effort,
summary="detailed" if model is None else auto_reasoning_summary(model, custom_llm_provider),
)
if auto_summary_enabled
else Reasoning(effort=reasoning_effort)
Reasoning(effort=reasoning_effort)
if summary is None
else Reasoning(effort=reasoning_effort, summary=summary)
)
_map_reasoning_effort = map_reasoning_effort

View file

@ -1096,6 +1096,7 @@ openai_compatible_providers: Final[list[str]] = [
"llamafile",
"lm_studio",
"galadriel",
"zerogpu",
"github_copilot", # GitHub Copilot Chat API
"chatgpt", # ChatGPT subscription API
"novita",

View file

@ -32,11 +32,11 @@ Usage:
# Create a managed agent on the provider side
result = litellm.interactions.agents.create(
name="waverunner",
id="waverunner",
custom_llm_provider="gemini",
api_key="...",
base_agent="gemini-2.5-flash",
instructions="You are a helpful assistant.",
base_agent="antigravity-preview-09-2026",
system_instruction="You are a helpful assistant.",
)
Methods:

View file

@ -3,11 +3,11 @@ litellm.interactions.agents
Full CRUD SDK for provider-side managed agents (e.g. Gemini v1beta/agents).
litellm.interactions.agents.create(name=..., ...)
litellm.interactions.agents.create(id=..., ...)
litellm.interactions.agents.list(api_key=...)
litellm.interactions.agents.get(name=..., ...)
litellm.interactions.agents.delete(name=..., ...)
litellm.interactions.agents.list_versions(name=..., ...)
litellm.interactions.agents.get(id=..., ...)
litellm.interactions.agents.delete(id=..., ...)
litellm.interactions.agents.list_versions(id=..., ...)
Async counterparts: acreate, alist, aget, adelete, alist_versions
"""

View file

@ -17,6 +17,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.types.agents import (
AgentCreateRequest,
AgentCreateResponse,
AgentDeleteResult,
AgentListResponse,
@ -35,7 +36,7 @@ class AgentsHTTPHandler(InteractionsHTTPHandler):
def create_agent(
self,
agents_api_config: BaseAgentsAPIConfig,
name: str,
create_request: AgentCreateRequest,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: dict[str, str] | None = None,
@ -47,7 +48,7 @@ class AgentsHTTPHandler(InteractionsHTTPHandler):
if _is_async:
return self.async_create_agent(
agents_api_config=agents_api_config,
name=name,
create_request=create_request,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
@ -63,12 +64,13 @@ class AgentsHTTPHandler(InteractionsHTTPHandler):
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
data: Final = agents_api_config.transform_create_request(name=name, litellm_params=dict(litellm_params))
if extra_body:
data.update(extra_body)
data: Final = {
**agents_api_config.transform_create_request(create_request),
**(extra_body or {}),
}
logging_obj.pre_call(
input=name,
input=create_request.id,
api_key="",
additional_args={
"complete_input_dict": data,
@ -85,12 +87,12 @@ class AgentsHTTPHandler(InteractionsHTTPHandler):
original_response=response.text,
additional_args={"complete_input_dict": data},
)
return agents_api_config.transform_create_response(raw_response=response, name=name)
return agents_api_config.transform_create_response(raw_response=response, name=create_request.id)
async def async_create_agent(
self,
agents_api_config: BaseAgentsAPIConfig,
name: str,
create_request: AgentCreateRequest,
litellm_params: GenericLiteLLMParams,
logging_obj: LiteLLMLoggingObj,
extra_headers: dict[str, str] | None = None,
@ -106,12 +108,13 @@ class AgentsHTTPHandler(InteractionsHTTPHandler):
api_base=litellm_params.get("api_base"),
litellm_params=dict(litellm_params),
)
data: Final = agents_api_config.transform_create_request(name=name, litellm_params=dict(litellm_params))
if extra_body:
data.update(extra_body)
data: Final = {
**agents_api_config.transform_create_request(create_request),
**(extra_body or {}),
}
logging_obj.pre_call(
input=name,
input=create_request.id,
api_key="",
additional_args={
"complete_input_dict": data,
@ -130,7 +133,7 @@ class AgentsHTTPHandler(InteractionsHTTPHandler):
original_response=response.text,
additional_args={"complete_input_dict": data},
)
return agents_api_config.transform_create_response(raw_response=response, name=name)
return agents_api_config.transform_create_response(raw_response=response, name=create_request.id)
# ------------------------------------------------------------------ #
# LIST #

View file

@ -6,41 +6,47 @@ Usage:
# Create
response = litellm.interactions.agents.create(
name="waverunner",
id="waverunner",
custom_llm_provider="gemini",
api_key="...",
base_agent="gemini-2.5-flash",
instructions="You are a helpful assistant.",
base_agent="antigravity-preview-09-2026",
system_instruction="You are a helpful assistant.",
agent_config={"type": "antigravity", "model": "gemini-3.8-flash"},
)
# List
response = litellm.interactions.agents.list(api_key="...", custom_llm_provider="gemini")
# Get
response = litellm.interactions.agents.get(name="waverunner", api_key="...")
response = litellm.interactions.agents.get(id="waverunner", api_key="...")
# Delete
result = litellm.interactions.agents.delete(name="waverunner", api_key="...")
result = litellm.interactions.agents.delete(id="waverunner", api_key="...")
# List versions
result = litellm.interactions.agents.list_versions(name="waverunner", api_key="...")
result = litellm.interactions.agents.list_versions(id="waverunner", api_key="...")
# Async versions: acreate, alist, aget, adelete, alist_versions
"""
import asyncio
import contextvars
from collections.abc import Coroutine, Mapping
from collections.abc import Coroutine, Mapping, Sequence
from dataclasses import dataclass
from functools import partial
from types import MappingProxyType
from typing import Any, Final
import httpx
from pydantic import ValidationError
import litellm
from litellm.interactions.agents.http_handler import agents_http_handler
from litellm.interactions.agents.utils import get_provider_agents_api_config
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.agents import (
AgentCreateRequest,
AgentCreateResponse,
AgentDeleteResult,
AgentListResponse,
@ -89,14 +95,120 @@ def _make_logging_obj(
return litellm_logging_obj
@dataclass(frozen=True, slots=True)
class _InvalidAgentRequest:
message: str
def _alias_conflict(field: str, value: str | None, alias: str, alias_value: str | None) -> str | None:
if value is None or alias_value is None or value == alias_value:
return None
return f"'{alias}' is an alias of '{field}', but the request gives them different values. Pass only '{field}'."
def _agent_id_or_error(id: str | None, name: str | None) -> str | _InvalidAgentRequest:
conflict: Final = _alias_conflict("id", id, "name", name)
if conflict is not None:
return _InvalidAgentRequest(conflict)
agent_id: Final = id if id is not None else name
if agent_id is None:
return _InvalidAgentRequest("Missing required parameter: 'id' (or its alias 'name').")
return agent_id
def _required_agent_id(id: str | None, name: str | None, llm_provider: str) -> str:
agent_id: Final = _agent_id_or_error(id, name)
if isinstance(agent_id, _InvalidAgentRequest):
raise litellm.BadRequestError(message=agent_id.message, model=id or name or "", llm_provider=llm_provider)
return agent_id
# ================================================================== #
# CREATE #
# ================================================================== #
_CREATE_FIELD_SHAPES: Final = MappingProxyType(
{
"id": "a string",
"base_agent": "a string",
"agent_config": "an object",
"system_instruction": "a string",
"base_environment": "an environment id string or an object",
"description": "a string",
"tools": "a list of objects",
}
)
def _invalid_create_fields_message(invalid_fields: frozenset[str], caller_names: Mapping[str, str]) -> str:
problems: Final = "; ".join(
f"'{caller_names.get(field, field)}' must be {_CREATE_FIELD_SHAPES[field]}" for field in sorted(invalid_fields)
)
drop_hint: Final = "" if "id" in invalid_fields else " Set drop_params to drop invalid optional fields."
return f"Invalid agent create request: {problems}.{drop_hint}"
def _validated_create_request(
fields: Mapping[str, object], drop_invalid_fields: bool, caller_names: Mapping[str, str]
) -> AgentCreateRequest | _InvalidAgentRequest:
try:
return AgentCreateRequest.model_validate(fields)
except ValidationError as e:
invalid_fields: Final = frozenset(str(error["loc"][0]) for error in e.errors())
if drop_invalid_fields and "id" not in invalid_fields:
return AgentCreateRequest.model_validate(
{field: value for field, value in fields.items() if field not in invalid_fields}
)
return _InvalidAgentRequest(_invalid_create_fields_message(invalid_fields, caller_names))
def _create_request_or_error(
id: str | None,
name: str | None,
base_agent: str | None,
agent_config: Mapping[str, object] | None,
system_instruction: str | None,
instructions: str | None,
base_environment: InteractionEnvironment | None,
description: str | None,
tools: Sequence[Mapping[str, object]] | None,
drop_invalid_fields: bool,
) -> AgentCreateRequest | _InvalidAgentRequest:
agent_id: Final = _agent_id_or_error(id, name)
if isinstance(agent_id, _InvalidAgentRequest):
return agent_id
instruction_conflict: Final = _alias_conflict(
"system_instruction", system_instruction, "instructions", instructions
)
if instruction_conflict is not None and not drop_invalid_fields:
return _InvalidAgentRequest(instruction_conflict)
sent_only_instructions: Final = system_instruction is None and instructions is not None
return _validated_create_request(
MappingProxyType(
{
"id": agent_id,
"base_agent": base_agent,
"agent_config": agent_config,
"system_instruction": instructions if sent_only_instructions else system_instruction,
"base_environment": base_environment,
"description": description,
"tools": tools,
}
),
drop_invalid_fields,
MappingProxyType(
{
"id": "name" if id is None else "id",
"system_instruction": "instructions" if sent_only_instructions else "system_instruction",
}
),
)
@client
async def acreate(
name: str,
id: str | None = None,
base_agent: str | None = None,
instructions: str | None = None,
base_environment: InteractionEnvironment | None = None,
@ -104,6 +216,12 @@ async def acreate(
extra_headers: dict[str, str] | None = None,
extra_body: Mapping[str, object] | None = None,
timeout: float | httpx.Timeout | None = None,
*,
name: str | None = None,
agent_config: Mapping[str, object] | None = None,
system_instruction: str | None = None,
description: str | None = None,
tools: Sequence[Mapping[str, object]] | None = None,
**kwargs,
) -> AgentCreateResponse:
"""Async: Create a managed agent on the provider side."""
@ -113,7 +231,7 @@ async def acreate(
kwargs["acreate_agent"] = True
func: Final = partial(
create,
name=name,
id=id,
base_agent=base_agent,
instructions=instructions,
base_environment=base_environment,
@ -121,6 +239,11 @@ async def acreate(
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout,
name=name,
agent_config=agent_config,
system_instruction=system_instruction,
description=description,
tools=tools,
**kwargs,
)
ctx: Final = contextvars.copy_context()
@ -130,7 +253,7 @@ async def acreate(
return init_response
except Exception as e:
raise litellm.exception_type(
model=name,
model=id or name or "",
custom_llm_provider=custom_llm_provider or "gemini",
original_exception=e,
completion_kwargs=local_vars,
@ -140,7 +263,7 @@ async def acreate(
@client
def create(
name: str,
id: str | None = None,
base_agent: str | None = None,
instructions: str | None = None,
base_environment: InteractionEnvironment | None = None,
@ -148,40 +271,61 @@ def create(
extra_headers: dict[str, str] | None = None,
extra_body: Mapping[str, object] | None = None,
timeout: float | httpx.Timeout | None = None,
*,
name: str | None = None,
agent_config: Mapping[str, object] | None = None,
system_instruction: str | None = None,
description: str | None = None,
tools: Sequence[Mapping[str, object]] | None = None,
**kwargs,
) -> AgentCreateResponse | Coroutine[object, object, AgentCreateResponse]:
"""
Sync: Create a managed agent on the provider side.
Args:
name: Name for the agent (required).
base_agent: Base agent to derive from (e.g. "waverunner").
instructions: System instructions for the agent.
id: Identifier for the agent. Exactly one of ``id`` or its alias ``name`` is required.
base_agent: Base agent to derive from (e.g. "antigravity-preview-09-2026").
instructions: Alias of ``system_instruction``.
base_environment: Environment to fork from — an env_id string or a
dict like ``{"type": "remote", "sources": [...]}``.
custom_llm_provider: Provider to use, e.g. "gemini".
extra_headers: Additional HTTP headers.
extra_body: Additional request body fields.
timeout: Request timeout.
name: Alias of ``id``.
agent_config: Agent runtime config, e.g. ``{"type": "antigravity", "model": "gemini-3.8-flash"}``.
system_instruction: System instruction for the agent.
description: Description of the agent for developers.
tools: Tools available to the agent, e.g. ``[{"type": "code_execution"}]``.
**kwargs: Forwarded to GenericLiteLLMParams (api_key, api_base, etc.).
"""
local_vars: Final = locals()
custom_llm_provider = custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
custom_llm_provider = custom_llm_provider or "gemini"
try:
_is_async: Final = kwargs.pop("acreate_agent", False) is True
if base_agent is not None:
kwargs["base_agent"] = base_agent
if instructions is not None:
kwargs["instructions"] = instructions
if base_environment is not None:
kwargs["base_environment"] = base_environment
create_request: Final = _create_request_or_error(
id=id,
name=name,
base_agent=base_agent,
agent_config=agent_config,
system_instruction=system_instruction,
instructions=instructions,
base_environment=base_environment,
description=description,
tools=tools,
drop_invalid_fields=drops_unsupported_params(kwargs.get("drop_params")),
)
if isinstance(create_request, _InvalidAgentRequest):
raise litellm.BadRequestError(
message=create_request.message, model=id or name or "", llm_provider=custom_llm_provider
)
kwargs.setdefault("custom_llm_provider", custom_llm_provider)
litellm_params: Final = GenericLiteLLMParams(**kwargs)
logging_obj: Final = _make_logging_obj(kwargs, name, custom_llm_provider, "create_agent", {})
logging_obj: Final = _make_logging_obj(kwargs, create_request.id, custom_llm_provider, "create_agent", {})
config: Final = _get_agents_api_config(custom_llm_provider)
return agents_http_handler.create_agent(
agents_api_config=config,
name=name,
create_request=create_request,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
@ -191,7 +335,7 @@ def create(
)
except Exception as e:
raise litellm.exception_type(
model=name,
model=id or name or "",
custom_llm_provider=custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
@ -247,7 +391,7 @@ def list(
) -> AgentListResponse | Coroutine[object, object, AgentListResponse]:
"""Sync: List all agents on the provider side."""
local_vars: Final = locals()
custom_llm_provider = custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
custom_llm_provider = custom_llm_provider or "gemini"
try:
_is_async: Final = kwargs.pop("alist_agents", False) is True
kwargs.setdefault("custom_llm_provider", custom_llm_provider)
@ -279,19 +423,22 @@ def list(
@client
async def aget(
name: str,
id: str | None = None,
custom_llm_provider: str | None = None,
extra_headers: dict[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
*,
name: str | None = None,
**kwargs,
) -> AgentCreateResponse:
"""Async: Get a specific agent by name."""
"""Async: Get a specific agent by id (``name`` is an alias of ``id``)."""
local_vars: Final = locals()
try:
loop: Final = asyncio.get_event_loop()
kwargs["aget_agent"] = True
func: Final = partial(
get,
id=id,
name=name,
custom_llm_provider=custom_llm_provider or "gemini",
extra_headers=extra_headers,
@ -305,7 +452,7 @@ async def aget(
return init_response
except Exception as e:
raise litellm.exception_type(
model=name,
model=id or name or "",
custom_llm_provider=custom_llm_provider or "gemini",
original_exception=e,
completion_kwargs=local_vars,
@ -315,24 +462,27 @@ async def aget(
@client
def get(
name: str,
id: str | None = None,
custom_llm_provider: str | None = None,
extra_headers: dict[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
*,
name: str | None = None,
**kwargs,
) -> AgentCreateResponse | Coroutine[object, object, AgentCreateResponse]:
"""Sync: Get a specific agent by name."""
"""Sync: Get a specific agent by id (``name`` is an alias of ``id``)."""
local_vars: Final = locals()
custom_llm_provider = custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
custom_llm_provider = custom_llm_provider or "gemini"
try:
_is_async: Final = kwargs.pop("aget_agent", False) is True
agent_id: Final = _required_agent_id(id, name, custom_llm_provider)
kwargs.setdefault("custom_llm_provider", custom_llm_provider)
litellm_params: Final = GenericLiteLLMParams(**kwargs)
logging_obj: Final = _make_logging_obj(kwargs, name, custom_llm_provider, "get_agent", {"name": name})
logging_obj: Final = _make_logging_obj(kwargs, agent_id, custom_llm_provider, "get_agent", {"name": agent_id})
config: Final = _get_agents_api_config(custom_llm_provider)
return agents_http_handler.get_agent(
agents_api_config=config,
name=name,
name=agent_id,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
@ -341,7 +491,7 @@ def get(
)
except Exception as e:
raise litellm.exception_type(
model=name,
model=id or name or "",
custom_llm_provider=custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
@ -356,19 +506,22 @@ def get(
@client
async def adelete(
name: str,
id: str | None = None,
custom_llm_provider: str | None = None,
extra_headers: dict[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
*,
name: str | None = None,
**kwargs,
) -> AgentDeleteResult:
"""Async: Delete a specific agent by name."""
"""Async: Delete a specific agent by id (``name`` is an alias of ``id``)."""
local_vars: Final = locals()
try:
loop: Final = asyncio.get_event_loop()
kwargs["adelete_agent"] = True
func: Final = partial(
delete,
id=id,
name=name,
custom_llm_provider=custom_llm_provider or "gemini",
extra_headers=extra_headers,
@ -382,7 +535,7 @@ async def adelete(
return init_response
except Exception as e:
raise litellm.exception_type(
model=name,
model=id or name or "",
custom_llm_provider=custom_llm_provider or "gemini",
original_exception=e,
completion_kwargs=local_vars,
@ -392,24 +545,29 @@ async def adelete(
@client
def delete(
name: str,
id: str | None = None,
custom_llm_provider: str | None = None,
extra_headers: dict[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
*,
name: str | None = None,
**kwargs,
) -> AgentDeleteResult | Coroutine[object, object, AgentDeleteResult]:
"""Sync: Delete a specific agent by name."""
"""Sync: Delete a specific agent by id (``name`` is an alias of ``id``)."""
local_vars: Final = locals()
custom_llm_provider = custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
custom_llm_provider = custom_llm_provider or "gemini"
try:
_is_async: Final = kwargs.pop("adelete_agent", False) is True
agent_id: Final = _required_agent_id(id, name, custom_llm_provider)
kwargs.setdefault("custom_llm_provider", custom_llm_provider)
litellm_params: Final = GenericLiteLLMParams(**kwargs)
logging_obj: Final = _make_logging_obj(kwargs, name, custom_llm_provider, "delete_agent", {"name": name})
logging_obj: Final = _make_logging_obj(
kwargs, agent_id, custom_llm_provider, "delete_agent", {"name": agent_id}
)
config: Final = _get_agents_api_config(custom_llm_provider)
return agents_http_handler.delete_agent(
agents_api_config=config,
name=name,
name=agent_id,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
@ -418,7 +576,7 @@ def delete(
)
except Exception as e:
raise litellm.exception_type(
model=name,
model=id or name or "",
custom_llm_provider=custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
@ -433,19 +591,22 @@ def delete(
@client
async def alist_versions(
name: str,
id: str | None = None,
custom_llm_provider: str | None = None,
extra_headers: dict[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
*,
name: str | None = None,
**kwargs,
) -> AgentVersionsResponse:
"""Async: List versions of a specific agent."""
"""Async: List versions of a specific agent by id (``name`` is an alias of ``id``)."""
local_vars: Final = locals()
try:
loop: Final = asyncio.get_event_loop()
kwargs["alist_agent_versions"] = True
func: Final = partial(
list_versions,
id=id,
name=name,
custom_llm_provider=custom_llm_provider or "gemini",
extra_headers=extra_headers,
@ -459,7 +620,7 @@ async def alist_versions(
return init_response
except Exception as e:
raise litellm.exception_type(
model=name,
model=id or name or "",
custom_llm_provider=custom_llm_provider or "gemini",
original_exception=e,
completion_kwargs=local_vars,
@ -469,24 +630,29 @@ async def alist_versions(
@client
def list_versions(
name: str,
id: str | None = None,
custom_llm_provider: str | None = None,
extra_headers: dict[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
*,
name: str | None = None,
**kwargs,
) -> AgentVersionsResponse | Coroutine[object, object, AgentVersionsResponse]:
"""Sync: List versions of a specific agent."""
"""Sync: List versions of a specific agent by id (``name`` is an alias of ``id``)."""
local_vars: Final = locals()
custom_llm_provider = custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
custom_llm_provider = custom_llm_provider or "gemini"
try:
_is_async: Final = kwargs.pop("alist_agent_versions", False) is True
agent_id: Final = _required_agent_id(id, name, custom_llm_provider)
kwargs.setdefault("custom_llm_provider", custom_llm_provider)
litellm_params: Final = GenericLiteLLMParams(**kwargs)
logging_obj: Final = _make_logging_obj(kwargs, name, custom_llm_provider, "list_agent_versions", {"name": name})
logging_obj: Final = _make_logging_obj(
kwargs, agent_id, custom_llm_provider, "list_agent_versions", {"name": agent_id}
)
config: Final = _get_agents_api_config(custom_llm_provider)
return agents_http_handler.list_agent_versions(
agents_api_config=config,
name=name,
name=agent_id,
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers=extra_headers,
@ -495,7 +661,7 @@ def list_versions(
)
except Exception as e:
raise litellm.exception_type(
model=name,
model=id or name or "",
custom_llm_provider=custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,

View file

@ -49,6 +49,7 @@ from litellm.interactions.utils import (
InteractionsAPIRequestUtils,
get_provider_interactions_api_config,
)
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.interactions import (
CancelInteractionResult,
@ -100,6 +101,8 @@ async def acreate(
timeout: float | httpx.Timeout | None = None,
# LiteLLM params
custom_llm_provider: str | None = None,
*,
agent_config: Mapping[str, object] | None = None,
**kwargs,
) -> InteractionsAPIResponse | AsyncIterator[InteractionsAPIStreamingResponse]:
"""
@ -121,6 +124,7 @@ async def acreate(
string, or a config object such as
``{"type": "remote", "sources": [...]}`` /
``{"type": "remote", "network": {...}}``
agent_config: Agent runtime config, e.g. ``{"type": "antigravity", "model": "gemini-3.8-flash"}``
response_modalities: Requested response modalities (TEXT, IMAGE, AUDIO)
response_format: JSON schema for response format
response_mime_type: MIME type of the response
@ -155,6 +159,7 @@ async def acreate(
store=store,
background=background,
environment=environment,
agent_config=agent_config,
response_modalities=response_modalities,
response_format=response_format,
response_mime_type=response_mime_type,
@ -225,6 +230,8 @@ def create(
timeout: float | httpx.Timeout | None = None,
# LiteLLM params
custom_llm_provider: str | None = None,
*,
agent_config: Mapping[str, object] | None = None,
**kwargs,
) -> (
InteractionsAPIResponse
@ -250,6 +257,7 @@ def create(
string, or a config object such as
``{"type": "remote", "sources": [...]}`` /
``{"type": "remote", "network": {...}}``
agent_config: Agent runtime config, e.g. ``{"type": "antigravity", "model": "gemini-3.8-flash"}``
response_modalities: Requested response modalities (TEXT, IMAGE, AUDIO)
response_format: JSON schema for response format
response_mime_type: MIME type of the response
@ -300,6 +308,15 @@ def create(
# Check if this is a bridge provider (litellm_responses) - similar to responses API
# Either provider is explicitly "litellm_responses" or no config found (bridge to responses)
if custom_llm_provider == "litellm_responses" or interactions_api_config is None:
if optional_params.get("agent_config") is not None and not drops_unsupported_params(
kwargs.get("drop_params")
):
raise litellm.UnsupportedParamsError(
message=f"'agent_config' is not supported for {custom_llm_provider} interactions. "
"Set drop_params to drop it.",
llm_provider=custom_llm_provider,
model=model,
)
# Bridge to litellm.responses() for non-native providers
from litellm.interactions.litellm_responses_transformation.handler import (
LiteLLMResponsesInteractionsHandler,

View file

@ -11,4 +11,4 @@ Core files:
- `duration_parser.py`: code for parsing durations - e.g. "1d", "1mo", "10s"
- `api_route_to_call_types.py`: mapping of API routes to their corresponding CallTypes (e.g., `/chat/completions` -> [acompletion, completion])
Tokenizer factories return Python tokenizer objects by default. Set `LITELLM_RUST=1` or call `litellm.rust(True)` before constructing tokenizers to select the Rust backend through `Route.TOKENIZER` in the Rust catalog. Missing native bindings or unsupported native features fall back to Python. Existing tokenizer objects keep their selected backend. Rust-backed tokenizer objects carry the read-only `tiktoken.Encoding` / `tokenizers.Tokenizer` surface and are immutable: `enable_padding`, `enable_truncation` and `add_tokens` stay on the Python tokenizer.
Tokenizer factories return Python tokenizer objects by default. Set `LITELLM_RUST=1` or call `litellm.rust(True)` before constructing tokenizers to select the Rust backend through `Route.TOKENIZER` in the Rust catalog. Unsupported native features fall back to Python. Existing tokenizer objects keep their selected backend. Rust-backed tokenizer objects carry the read-only `tiktoken.Encoding` / `tokenizers.Tokenizer` surface and are immutable: `enable_padding`, `enable_truncation` and `add_tokens` stay on the Python tokenizer.

View file

@ -2,8 +2,12 @@
Utils used for litellm.transcription() and litellm.atranscription()
"""
import base64
import binascii
import hashlib
import math
import os
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Final
@ -331,6 +335,45 @@ def calculate_request_duration(file: FileTypes) -> float | None:
return None
DEFAULT_AUDIO_TOKEN_ESTIMATE: Final = 300
AUDIO_TOKENS_PER_SECOND: Final = 10
AUDIO_BYTES_PER_TOKEN: Final = 1600
_MAX_AUDIO_ESTIMATE_BYTES: Final = 16 * 1024 * 1024
def _audio_token_rate(model: str) -> int | None:
if model.startswith(
("gemini-", "gemini/gemini-", "vertex_ai/gemini-", "vertex_ai_beta/gemini-", "openrouter/google/gemini-")
):
family: Final = model.rsplit("/", 1)[-1]
return 25 if family.startswith(("gemini-3-", "gemini-3.")) else 32
if model.startswith(("gpt-", "openai/gpt-", "azure/gpt-", "openrouter/openai/gpt-")):
return AUDIO_TOKENS_PER_SECOND
return None
def estimate_audio_tokens(data: str | None, models: Sequence[str] = (), fallback_model: str | None = None) -> int:
"""Estimate audio tokens from container duration within a bounded decoding budget."""
if not data:
return DEFAULT_AUDIO_TOKEN_ESTIMATE
fallback_estimate: Final = max(len(data) * 3 // 4 // AUDIO_BYTES_PER_TOKEN, DEFAULT_AUDIO_TOKEN_ESTIMATE)
padding: Final = 2 if data.endswith("==") else 1 if data.endswith("=") else 0
if len(data) * 3 // 4 - padding > _MAX_AUDIO_ESTIMATE_BYTES:
return fallback_estimate
try:
duration: Final = calculate_request_duration(base64.b64decode(data, validate=True))
except (ValueError, binascii.Error):
return fallback_estimate
if duration is None or not math.isfinite(duration) or duration <= 0:
return fallback_estimate
fallback_rate: Final = _audio_token_rate(fallback_model) if fallback_model is not None else None
rate: Final = max(
(_audio_token_rate(model) or fallback_rate or AUDIO_TOKENS_PER_SECOND for model in models),
default=fallback_rate or AUDIO_TOKENS_PER_SECOND,
)
return max(1, math.ceil(duration * rate))
DEFAULT_SPEECH_MEDIA_TYPE: Final = "audio/mpeg"

View file

@ -353,9 +353,9 @@ def is_cli_token_fresh(
buffer_hours: float = CLI_TOKEN_FRESHNESS_BUFFER_SECONDS / 3600,
) -> bool:
"""Check whether a cached CLI token is still within its expiration window.
Used by `lite auth print-token` to fail fast, without a network round trip,
once the cached token is past `LITELLM_CLI_JWT_EXPIRATION_HOURS`. A `--pkce`
credential carries its own `expires_at`, which is authoritative when present."""
Used by `lite auth print-token` to skip asking the proxy while the cached token
is younger than `LITELLM_CLI_JWT_EXPIRATION_HOURS`. A `--pkce` credential
carries its own `expires_at`, which is authoritative when present."""
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
expires_at: Final = (

View file

@ -9,6 +9,7 @@ import json
import mimetypes
import re
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from graphlib import CycleError, TopologicalSorter
from itertools import chain, groupby, islice
from os import PathLike
@ -236,6 +237,160 @@ _IMAGE_CONTENT_PART_TYPES: Final = frozenset({"image_url", "input_image", "image
_IMAGE_SCAN_MAX_DEPTH: Final = 4
@dataclass(frozen=True, slots=True)
class _TPMMediaProjection:
content: object
media: tuple[Mapping[str, object], ...] = ()
def _tpm_image_part(url: str, detail: object = "auto") -> Mapping[str, object]:
return {"type": "image_url", "image_url": {"url": url, "detail": detail}}
def _tpm_blank_fields(fields: Mapping[str, object], names: tuple[str, ...]) -> Mapping[str, object]:
return {key: "" if key in names and isinstance(value, str) else value for key, value in fields.items()}
def _tpm_file_media(part: Mapping[str, object]) -> _TPMMediaProjection:
from litellm.litellm_core_utils.prompt_templates.factory import parse_mime_type
nested: Final = part.get("type") == "file"
fields: Final = as_str_mapping(part.get("file")) if nested else part
if fields is None:
return _TPMMediaProjection(part)
raw_data: Final = fields.get("file_data")
data: Final = raw_data if isinstance(raw_data, str) else ""
declared: Final = next(
(
value
for key in ("format", "mime_type", "content_type")
if isinstance(value := fields.get(key), str) and value
),
"",
)
mime: Final = (declared or parse_mime_type(data[:256]) or "").split(";", 1)[0].strip().lower()
if not mime.startswith(("image/", "audio/")):
return _TPMMediaProjection(part)
raw_url: Final = fields.get("file_url") or fields.get("file_id")
url: Final = data or (raw_url if isinstance(raw_url, str) else "")
header_end: Final = data.find(",", 0, 256)
encoded: Final = (
data[header_end + 1 :]
if header_end >= 0 and data[:header_end].endswith(";base64")
else ""
if data.startswith(("data:", "https://", "http://", "gs://"))
else data
)
media: Final = (
_tpm_image_part(
url
if not data or url.startswith(("data:", "https://", "http://", "gs://"))
else f"data:{mime};base64,{url}",
fields.get("detail", "auto"),
)
if mime.startswith("image/")
else {"type": "input_audio", "input_audio": {"data": encoded, "format": mime.removeprefix("audio/")}}
)
projected: Final = _tpm_blank_fields(fields, ("file_data", "file_url"))
return _TPMMediaProjection({**part, "file": projected} if nested else projected, (media,))
def _tpm_media_leaf(value: object) -> _TPMMediaProjection:
part: Final = as_str_mapping(value)
if part is None:
return _TPMMediaProjection(value)
kind: Final = part.get("type")
if kind in ("file", "input_file"):
return _tpm_file_media(part)
if kind == "input_audio":
audio: Final = as_str_mapping(part.get("input_audio"))
return _TPMMediaProjection(
{**part, "input_audio": _tpm_blank_fields(audio, ("data",))} if audio is not None else value,
({"type": "input_audio", "input_audio": part.get("input_audio")},),
)
if kind in ("image_url", "input_image", "image", "computer_screenshot"):
image: Final = as_str_mapping(part.get("image_url"))
source: Final = as_str_mapping(part.get("source"))
canonical: Final = _tpm_image_part(
_image_part_url({**part, "type": "input_image"} if kind == "computer_screenshot" else part) or "",
(image or part).get("detail", part.get("detail", "auto")),
)
projected: Final = (
{**part, "image_url": _tpm_blank_fields(image, ("url",))}
if image is not None and kind in ("image_url", "input_image", "computer_screenshot")
else {**part, "source": _tpm_blank_fields(source, ("data", "url"))}
if source is not None and kind == "image" and source.get("type") in ("base64", "url")
else _tpm_blank_fields(part, ("image_url", "url"))
)
return _TPMMediaProjection(projected, (canonical,))
return _TPMMediaProjection(value)
def _tpm_media_list(value: object, split: Callable[[object], _TPMMediaProjection]) -> _TPMMediaProjection:
if not isinstance(value, (list, tuple)):
return _TPMMediaProjection(value)
parts: Final = cast(Sequence[object], value) # cast-ok: finite JSON content arrays
projected: Final = tuple(map(split, parts))
media: Final = tuple(chain.from_iterable(part.media for part in projected))
content: Final = tuple(part.content for part in projected)
return _TPMMediaProjection(parts if not media else content if isinstance(value, tuple) else list(content), media)
def _tpm_replace_field(part: Mapping[str, object], key: str, projection: _TPMMediaProjection) -> _TPMMediaProjection:
return _TPMMediaProjection({**part, key: projection.content} if projection.media else part, projection.media)
def _tpm_document_media(value: object) -> _TPMMediaProjection:
part: Final = as_str_mapping(value)
source: Final = as_str_mapping(part.get("source")) if part is not None else None
if part is None or part.get("type") != "document" or source is None or source.get("type") != "content":
return _tpm_media_leaf(value)
content: Final = _tpm_media_list(source.get("content"), _tpm_media_leaf)
return _tpm_replace_field(part, "source", _tpm_replace_field(source, "content", content))
def _tpm_tool_result_media(value: object) -> _TPMMediaProjection:
part: Final = as_str_mapping(value)
if part is None or part.get("type") != "tool_result":
return _tpm_document_media(value)
return _tpm_replace_field(part, "content", _tpm_media_list(part.get("content"), _tpm_document_media))
def _tpm_code_output_media(value: object) -> _TPMMediaProjection:
part: Final = as_str_mapping(value)
url: Final = part.get("url") if part is not None else None
if part is None or part.get("type") != "image" or not isinstance(url, str) or not url:
return _TPMMediaProjection(value)
return _TPMMediaProjection(_tpm_blank_fields(part, ("url",)), (_tpm_image_part(url),))
def _tpm_input_media(value: object) -> _TPMMediaProjection:
part: Final = as_str_mapping(value)
if part is None:
return _TPMMediaProjection(value)
kind: Final = part.get("type")
if kind in ("function_call_output", "custom_tool_call_output"):
return _tpm_replace_field(part, "output", _tpm_media_list(part.get("output"), _tpm_tool_result_media))
if kind == "computer_call_output":
return _tpm_replace_field(part, "output", _tpm_media_leaf(part.get("output")))
if kind == "code_interpreter_call":
return _tpm_replace_field(part, "outputs", _tpm_media_list(part.get("outputs"), _tpm_code_output_media))
result: Final = part.get("result")
if kind == "image_generation_call" and isinstance(result, str) and result:
return _TPMMediaProjection(
_tpm_blank_fields(part, ("result",)),
(_tpm_image_part("data:application/octet-stream;base64," + result),),
)
if kind == "message" or (kind is None and isinstance(part.get("role"), str)):
return _tpm_replace_field(part, "content", _tpm_media_list(part.get("content"), _tpm_tool_result_media))
return _tpm_tool_result_media(value)
def split_media_for_tpm(value: object) -> tuple[object, tuple[Mapping[str, object], ...]]:
projection: Final = _tpm_media_list(value, _tpm_input_media)
return projection.content, projection.media
def _content_parts_contain_image(parts: Sequence[object]) -> bool:
"""Depth-bounded frontier walk over nested content lists, iterative because the repo bans
recursion; an Anthropic tool_result nests its image parts exactly one level down."""

View file

@ -1557,6 +1557,12 @@ class CustomStreamWrapper:
else: # openai / azure chat model
if isinstance(chunk, ChatCompletionChunk):
self._openai_handler_receives_chat_chunks = True
if any(str(choice.finish_reason) == "abort" for choice in chunk.choices or ()):
raise litellm.InternalServerError(
message="Upstream generation aborted (finish_reason=abort)",
model=chunk.model,
llm_provider=self.custom_llm_provider,
)
if self.custom_llm_provider in [
LlmProviders.AZURE.value,
LlmProviders.AZURE_AI.value,

View file

@ -3,9 +3,12 @@
import base64
import io
import math
import re
import struct
from bisect import bisect_right
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from itertools import accumulate
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Literal, cast
import anyio
@ -35,6 +38,7 @@ from litellm.constants import (
TOKEN_COUNTER_MAX_EXACT_CHARS,
)
from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.litellm_core_utils.prompt_templates.chat_template_tools import (
ChatTemplateToolSection,
chat_template_tool_section,
@ -188,22 +192,23 @@ def resize_image_high_res(
# Test the function with the given example
def calculate_tiles_needed(
resized_width,
resized_height,
tile_width=MAX_TILE_WIDTH,
tile_height=MAX_TILE_HEIGHT,
):
resized_width: int,
resized_height: int,
tile_width: int = MAX_TILE_WIDTH,
tile_height: int = MAX_TILE_HEIGHT,
) -> int:
tiles_across: Final = (resized_width + tile_width - 1) // tile_width
tiles_down: Final = (resized_height + tile_height - 1) // tile_height
total_tiles: Final = tiles_across * tiles_down
return total_tiles
def high_detail_image_token_upper_bound(base_tokens: int = 85) -> int:
def high_detail_image_token_upper_bound(base_tokens: int = 85, tile_tokens: int | None = None) -> int:
largest_tile_count: Final = calculate_tiles_needed(
MAX_LONG_SIDE_FOR_IMAGE_HIGH_RES, MAX_SHORT_SIDE_FOR_IMAGE_HIGH_RES
)
return base_tokens + (base_tokens * 2) * largest_tile_count
per_tile: Final = base_tokens * 2 if tile_tokens is None else tile_tokens
return base_tokens + per_tile * largest_tile_count
def _unpack_ints(fmt: str, buffer: bytes) -> tuple[int, ...]:
@ -381,6 +386,219 @@ def calculate_img_tokens(
return total_tokens
_IMAGE_HEADER_BASE64_CHARS: Final = 65_536
_MAX_IMAGE_PATCHES: Final = 30_000
_ANTHROPIC_IMAGE_LIMITS: Final = MappingProxyType(
{"claude-standard": (ANTHROPIC_IMAGE_MAX_LONG_EDGE_PX, 1568), "claude-high": (2576, 4784)}
)
_TILE_IMAGE_TOKEN_COSTS: Final = MappingProxyType(
{
"gpt-4o": (85, 170),
"gpt-4.1": (85, 170),
"gpt-4o-mini": (2833, 5667),
"gpt-5": (70, 140),
"gpt-5.1": (70, 140),
"o1": (75, 150),
"o1-pro": (75, 150),
"o3": (75, 150),
}
)
_PATCH_IMAGE_MULTIPLIERS: Final = MappingProxyType(
{
"gpt-6-astra": 1.2,
"gpt-5.6-sol": 1.2,
"gpt-5.6-terra": 1.2,
"gpt-5.6-luna": 1.2,
"gpt-5.5": 1.2,
"gpt-5.4": 1.2,
"gpt-5.4-mini": 1.2,
"gpt-5.4-nano": 1.2,
"gpt-5.2": 1.2,
"gpt-4.1-mini": 1.62,
}
)
def _inline_image_dimensions(data: str) -> tuple[int, int] | None:
separator: Final = data.find(",", 0, 256)
if not data.startswith("data:") or separator < 0 or not data[:separator].endswith(";base64"):
return None
encoded_header: Final = data[separator + 1 : separator + 1 + _IMAGE_HEADER_BASE64_CHARS]
try:
header: Final = base64.b64decode(encoded_header, validate=True)
image_type: Final = get_image_type(header)
if image_type not in ("png", "gif", "jpeg", "webp"):
return None
if image_type == "webp" and header[12:16] not in (b"VP8X", b"VP8 ", b"VP8L"):
return None
dimensions: Final = _image_dimensions_from_bytes(header)
except (ValueError, TypeError, struct.error):
return None
width, height = dimensions
return (width, height) if width > 0 and height > 0 else None
def _patch_image_limits(model: str, detail: str) -> tuple[int, int | None] | None:
if model in ("gpt-5.2", "gpt-4.1-mini"):
return None if detail == "original" else (2048, 6144)
original_default: Final = model in ("gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5")
effective_detail: Final = ("original" if original_default else "high") if detail == "auto" else detail
if effective_detail == "low":
return (2048, 6144) if model in ("gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano") else (512, None)
if effective_detail == "high":
return (65_535 if model == "gpt-6-astra" else 2048, 2500)
return (
(65_535, None) if model in ("gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna") else (6000, 10_000)
)
def _image_patch_count(width: int, height: int, max_dimension: int, budget: int | None) -> int:
dimension_scale: Final = min(1.0, max_dimension / max(width, height))
fitted_width: Final = max(1, round(width * dimension_scale))
fitted_height: Final = max(1, round(height * dimension_scale))
patches: Final = math.ceil(fitted_width / 32) * math.ceil(fitted_height / 32)
if budget is None or patches <= budget:
return patches
shrink: Final = math.sqrt(32**2 * budget / (fitted_width * fitted_height))
width_patches: Final = fitted_width * shrink / 32
height_patches: Final = fitted_height * shrink / 32
aligned_shrink: Final = shrink * min(
math.floor(width_patches) / width_patches, math.floor(height_patches) / height_patches
)
resized_width: Final = max(1, math.floor(fitted_width * aligned_shrink))
resized_height: Final = max(1, math.floor(fitted_height * aligned_shrink))
return math.ceil(resized_width / 32) * math.ceil(resized_height / 32)
def _image_policy_model_name(model: str) -> str | None:
provider, separator, provider_model = model.partition("/")
if not separator:
return model
if provider == "openai":
return provider_model
if provider == "openrouter":
return provider_model.removeprefix("openai/") if provider_model.startswith("openai/") else None
if provider not in ("azure", "azure_ai"):
return None
catalog_entry: Final = as_str_mapping(
cast( # cast-ok: read-only view of the legacy string-keyed registry; as_str_mapping checks the entry
Mapping[str, object], litellm.model_cost
).get(model)
)
if catalog_entry is not None and catalog_entry.get("litellm_provider") in ("azure", "azure_ai"):
return provider_model.rsplit("/", 1)[-1]
return provider_model.removeprefix("responses/").removeprefix("o_series/").removeprefix("openai/")
def _anthropic_image_family(model: str) -> str | None:
from litellm.utils import get_potential_model_names
provider, separator, _ = model.partition("/")
if separator and provider not in (
"anthropic",
"bedrock",
"bedrock_converse",
"bedrock_mantle",
"vertex_ai",
"vertex_ai_beta",
"azure_ai",
"openrouter",
):
return None
candidates: Final = get_potential_model_names(model, provider if separator else "anthropic")
catalog: Final = cast(Mapping[str, object], litellm.model_cost) # cast-ok: read-only view of the legacy registry
for candidate in dict.fromkeys(candidates.values()):
if not isinstance(candidate, str):
continue
if (entry := as_str_mapping(catalog.get(candidate))) is None or entry.get("supports_vision") is not True:
continue
if (
version := re.search(
r"(?:^|[./])claude-(?:(?:haiku|sonnet|opus)-)?(\d+)(?:[-.](\d{1,2})(?!\d))?", candidate
)
) is None:
continue
major, minor = int(version[1]), int(version[2] or 0)
return "claude-high" if (major, minor) >= (4, 7) else "claude-standard"
return None
def _anthropic_image_tokens(dimensions: tuple[int, int] | None, limits: tuple[int, int]) -> int:
max_edge, token_budget = limits
if dimensions is None:
return token_budget
long_edge, short_edge = max(dimensions), min(dimensions)
aspect_ratio: Final = long_edge / short_edge
def tokens_at_edge(edge: int) -> int:
return math.ceil(edge / 28) * math.ceil(max(round(edge / aspect_ratio), 1) / 28)
fitted_edge: Final = bisect_right(range(1, min(long_edge, max_edge) + 1), token_budget, key=tokens_at_edge)
return tokens_at_edge(max(fitted_edge, 1))
def _image_model_family(model: str) -> str | None:
provider_model: Final = _image_policy_model_name(model)
if provider_model is None:
return _anthropic_image_family(model)
family: Final = re.sub(r"-\d{4}-\d{2}-\d{2}$", "", provider_model)
canonical: Final = litellm.azure_llms.get(f"azure/{family}", family) if model.startswith("azure/") else family
return (
canonical
if canonical in _TILE_IMAGE_TOKEN_COSTS or canonical in _PATCH_IMAGE_MULTIPLIERS
else _anthropic_image_family(model)
)
def _image_policy_tokens(family: str | None, dimensions: tuple[int, int] | None, detail: str) -> int:
if family is None:
return DEFAULT_IMAGE_TOKEN_COUNT
anthropic_limits: Final = _ANTHROPIC_IMAGE_LIMITS.get(family)
if anthropic_limits is not None:
return _anthropic_image_tokens(dimensions, anthropic_limits)
tile_costs: Final = _TILE_IMAGE_TOKEN_COSTS.get(family)
multiplier: Final = _PATCH_IMAGE_MULTIPLIERS.get(family)
if tile_costs is not None:
base_tokens, tile_tokens = tile_costs
if detail == "original":
return DEFAULT_IMAGE_TOKEN_COUNT
if detail == "low":
return base_tokens
if dimensions is None:
return high_detail_image_token_upper_bound(base_tokens, tile_tokens)
width, height = dimensions
scale: Final = min(1.0, 2048 / max(width, height), 768 / min(width, height))
tiles: Final = calculate_tiles_needed(max(1, math.floor(width * scale)), max(1, math.floor(height * scale)))
return base_tokens + tiles * tile_tokens
limits: Final = _patch_image_limits(family, detail)
if limits is None or multiplier is None:
return DEFAULT_IMAGE_TOKEN_COUNT
max_dimension, patch_budget = limits
patches: Final = (
min(math.ceil(max_dimension / 32) ** 2, patch_budget if patch_budget is not None else _MAX_IMAGE_PATCHES)
if dimensions is None
else _image_patch_count(*dimensions, max_dimension, patch_budget)
)
return math.ceil(patches * multiplier)
def estimate_image_tokens(
model: str | Sequence[str], data: str, detail: str = "auto", *, fallback_model: str | None = None
) -> int:
"""Estimate image policies from at most 48 KiB of inline headers, without fetching media."""
if detail not in ("low", "high", "auto", "original"):
return DEFAULT_IMAGE_TOKEN_COUNT
models: Final = (model,) if isinstance(model, str) else model
fallback_family: Final = _image_model_family(fallback_model) if fallback_model is not None else None
families: Final = tuple(
dict.fromkeys(_image_model_family(candidate) or fallback_family for candidate in models or ("",))
)
if all(family is None for family in families):
return DEFAULT_IMAGE_TOKEN_COUNT
dimensions: Final = _inline_image_dimensions(data)
return max(_image_policy_tokens(family, dimensions, detail) for family in families)
TokenCounterFunction = Callable[[str], int]
"""
Type for a function that counts tokens in a string.

View file

@ -82,4 +82,6 @@ class AiohttpOpenAIChatConfig(OpenAILikeChatConfig):
model_response.model = _json_response.get("model")
model_response.object = _json_response.get("object")
model_response.system_fingerprint = _json_response.get("system_fingerprint")
if "service_tier" in _json_response:
return model_response.model_copy(update={"service_tier": _json_response.get("service_tier")})
return model_response

View file

@ -15,6 +15,7 @@ from collections.abc import Mapping
import httpx
from litellm.types.agents import (
AgentCreateRequest,
AgentCreateResponse,
AgentDeleteResult,
AgentListResponse,
@ -50,10 +51,9 @@ class BaseAgentsAPIConfig(ABC):
@abstractmethod
def transform_create_request(
self,
name: str,
litellm_params: Mapping[str, object],
) -> dict[str, object]:
"""Map name + litellm_params to the provider's create-agent body."""
create_request: AgentCreateRequest,
) -> Mapping[str, object]:
"""Map the create request to the provider's create-agent body."""
@abstractmethod
def transform_create_response(

View file

@ -20,16 +20,13 @@ from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig
from litellm.llms.gemini.common_utils import GeminiError, GeminiModelInfo
from litellm.types.agents import (
AgentCreateRequest,
AgentCreateResponse,
AgentDeleteResult,
AgentListResponse,
AgentVersionsResponse,
)
# Keys inside litellm_params that should be forwarded to the Gemini
# create-agent body verbatim.
_GEMINI_AGENT_BODY_KEYS: Final = ("base_agent", "instructions", "base_environment")
class _JsonResponse(Protocol):
def json(self) -> object: ...
@ -39,30 +36,6 @@ def _json_body(response: _JsonResponse) -> object:
return response.json()
# LiteLLM-internal keys that must never be forwarded to Gemini.
_LITELLM_INTERNAL_KEYS: Final = frozenset(
{
"custom_llm_provider",
"api_key",
"api_base",
"make_public",
"cost_per_query",
"input_cost_per_token",
"output_cost_per_token",
"require_trace_id_on_calls_to_agent",
"require_trace_id_on_calls_by_agent",
"max_iterations",
"max_budget_per_session",
"guardrails",
"is_public",
"agent_name",
"agent_id",
"agent_card_params",
"provider_agent_response",
}
)
class GeminiAgentsConfig(BaseAgentsAPIConfig):
"""
Configuration for the Google AI Studio (Gemini) native Agents API.
@ -143,14 +116,9 @@ class GeminiAgentsConfig(BaseAgentsAPIConfig):
def transform_create_request(
self,
name: str,
litellm_params: Mapping[str, object],
) -> dict[str, object]:
body: Final[dict[str, object]] = {"name": name}
for key in _GEMINI_AGENT_BODY_KEYS:
value = litellm_params.get(key)
if value is not None:
body[key] = value
create_request: AgentCreateRequest,
) -> Mapping[str, object]:
body: Final[Mapping[str, object]] = create_request.model_dump(mode="json", exclude_none=True)
verbose_logger.debug("GeminiAgentsConfig create body: %s", body)
return body

View file

@ -15,7 +15,7 @@ import httpx
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, process_response_headers
from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_api_base
from litellm.llms.base_llm.interactions.transformation import BaseInteractionsAPIConfig
from litellm.llms.gemini.common_utils import GeminiError, GeminiModelInfo
@ -41,6 +41,14 @@ else:
_JsonObject: TypeAlias = dict[str, object]
def _has_agent_config_type(agent_config: object) -> bool:
match agent_config:
case {"type": str()}:
return True
case _:
return False
class _InteractionPayload(TypedDict, total=False):
"""JSON body of an Interactions API interaction, keyed as ``InteractionsAPIResponse`` fields."""
@ -105,6 +113,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
return [
"model",
"agent",
"agent_config",
"input",
"tools",
"system_instruction",
@ -188,6 +197,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
request_body["input"] = input
optional_keys: Final = [
"agent_config",
"tools",
"system_instruction",
"stream",
@ -197,8 +207,14 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
"response_modalities",
"previous_interaction_id",
]
dropped_keys: Final = (
frozenset({"agent_config"})
if drops_unsupported_params(litellm_params.get("drop_params"))
and (model or not _has_agent_config_type(optional_params.get("agent_config")))
else frozenset()
)
for key in optional_keys:
if optional_params.get(key) is not None:
if key not in dropped_keys and optional_params.get(key) is not None:
request_body[key] = optional_params[key]
# response_mime_type is removed — fold it into response_format.

View file

@ -0,0 +1,17 @@
from typing import Final
from litellm.constants import PLACEHOLDER_API_KEY
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
class OllamaDecisionsConfig(BaseDecisionsConfig):
api_key_env = ("OLLAMA_API_KEY",)
api_base_env = ("OLLAMA_API_BASE",)
api_key_required = False
def get_default_api_base(self) -> str | None:
return "http://localhost:11434"
def resolve_api_key(self, api_key: str | None) -> str | None:
resolved: Final = super().resolve_api_key(api_key)
return None if resolved == PLACEHOLDER_API_KEY else resolved

View file

@ -76,6 +76,7 @@ else:
_NO_TOOLS_UPDATE: Final[Mapping[str, object]] = MappingProxyType({})
_OBJECT_MAPPING: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
_CHOICE_MAPPINGS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
_DECODED_JSON: Final = TypeAdapter(object)
@ -958,8 +959,13 @@ class OpenAIChatCompletionStreamingHandler(BaseModelResponseIterator):
status_code=error_status_code,
message=error_message,
)
choices = chunk.get("choices", [])
choices = self._map_reasoning_to_reasoning_content(choices)
raw_choices: Final = chunk.get("choices") or []
if any(choice.get("finish_reason") == "abort" for choice in _CHOICE_MAPPINGS.validate_python(raw_choices)):
raise OpenAIError(
status_code=500,
message="Upstream generation aborted (finish_reason=abort)",
)
choices: Final = self._map_reasoning_to_reasoning_content(raw_choices)
fingerprint: Final = _OBJECT_MAPPING.validate_python(chunk).get("system_fingerprint")
kwargs: Final[dict[str, Any]] = {

View file

@ -58,6 +58,12 @@
"max_completion_tokens": "max_tokens"
}
},
"zerogpu": {
"base_url": "https://api.zerogpu.ai/v1",
"api_key_env": "ZEROGPU_API_KEY",
"api_base_env": "ZEROGPU_API_BASE",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
},
"chutes": {
"base_url": "https://llm.chutes.ai/v1/",
"api_key_env": "CHUTES_API_KEY",

View file

@ -6124,7 +6124,18 @@ def completion(
optional_params, rs_val = strip_reasoning_summary_aliases_from_optional_params(optional_params)
if isinstance(resolved_reasoning_effort, dict) and "summary" in resolved_reasoning_effort:
if custom_llm_provider == "openai" and isinstance(resolved_reasoning_effort, dict):
from litellm.responses.utils import openai_bridge_reasoning
optional_params["reasoning_effort"] = openai_bridge_reasoning(
requested=resolved_reasoning_effort,
effort_kept="reasoning_effort" in optional_params,
summary_alias=rs_val,
model=model,
drop_params=drop_unsupported_reasoning,
additional_drop_params=routing_kwargs.get("additional_drop_params"),
)
elif isinstance(resolved_reasoning_effort, dict) and "summary" in resolved_reasoning_effort:
optional_params["reasoning_effort"] = resolved_reasoning_effort
elif rs_val is not None:
eff: Final = optional_params.get("reasoning_effort", resolved_reasoning_effort)

File diff suppressed because it is too large Load diff

View file

@ -1225,7 +1225,7 @@
"responses": true,
"text_completion": true,
"embeddings": true,
"image_generations": false,
"image_generations": true,
"image_edits": false,
"audio_transcriptions": false,
"audio_speech": false,
@ -4437,6 +4437,32 @@
"generateContent": true
}
},
"zerogpu": {
"display_name": "ZeroGPU (`zerogpu`)",
"url": "https://docs.litellm.ai/docs/providers/zerogpu",
"endpoints": {
"chat_completions": true,
"responses": true,
"text_completion": true,
"embeddings": false,
"image_generations": false,
"image_edits": false,
"audio_transcriptions": false,
"audio_speech": false,
"rerank": false,
"ocr": false,
"search": false,
"moderations": false,
"realtime": false,
"video_generations": false,
"batches": false,
"messages": true,
"interactions": true,
"systemone": false,
"a2a": true,
"generateContent": true
}
},
"ragflow": {
"display_name": "RAGFlow (`ragflow`)",
"url": "https://docs.litellm.ai/docs/providers/ragflow",

View file

@ -15015,7 +15015,7 @@
]
},
"post": {
"description": "Create a named custom agent on the Gemini side.\n\nExample:\n```bash\ncurl -X POST \"http://localhost:4000/v1beta/agents\" \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"my-custom-slides-agent\",\n \"base_agent\": \"waverunner\",\n \"instructions\": \"You are a helpful assistant that creates slides.\",\n \"base_environment\": {\n \"type\": \"remote\",\n \"sources\": [\n {\"type\": \"gcs\", \"source\": \"gs://eap-templates/slides-skill\",\n \"target\": \"/.agents/skills/slides-skill\"}\n ]\n }\n }'\n```",
"description": "Create a named custom agent on the Gemini side.\n\nExample:\n```bash\ncurl -X POST \"http://localhost:4000/v1beta/agents\" \\\n -H \"Authorization: Bearer sk-...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"id\": \"my-custom-slides-agent\",\n \"base_agent\": \"antigravity-preview-09-2026\",\n \"system_instruction\": \"You are a helpful assistant that creates slides.\",\n \"agent_config\": {\"type\": \"antigravity\", \"model\": \"gemini-3.8-flash\"},\n \"base_environment\": {\n \"type\": \"remote\",\n \"sources\": [\n {\"type\": \"gcs\", \"source\": \"gs://eap-templates/slides-skill\",\n \"target\": \"/.agents/skills/slides-skill\"}\n ]\n }\n }'\n```",
"operationId": "create_gemini_agent_v1beta_agents_post",
"responses": {
"200": {

View file

@ -342,7 +342,7 @@ The CLI provides these authentication commands:
- **`lite login --pkce`** - Sign in through the system browser with OAuth authorization code + PKCE; the key renews itself with a refresh token
- **`lite logout`** - Clear stored authentication token (and revoke a `--pkce` refresh token on the proxy)
- **`lite whoami`** - Show current authentication status
- **`lite auth print-token`** - Print the cached token (used as Claude Code's `apiKeyHelper`); renews a `--pkce` key first and fails once a classic token has expired
- **`lite auth print-token`** - Print the cached token (used as Claude Code's `apiKeyHelper`); renews a `--pkce` key first, and fails once a classic token is past 24 hours and the proxy rejects it
### Authentication Flow Steps
@ -382,7 +382,7 @@ Keychain storage needs the `keyring` package, which ships with `pip install 'lit
`lite logout` clears both stores. If the keychain is locked at that moment it says so, and re-running it once the keychain is unlocked finishes the job.
The stored credential is a short-lived, per-session agent token, not a managed virtual key. It is scoped to the user and team you logged in as and inherits their models and budgets; spend is tracked against the shared team and user budgets rather than a separate per-session cap, so multiple logins or several concurrent agents all draw down the same allowance. It is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); re-run `lite login` to refresh it and pick up your latest team and user settings. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while fresh and fails once it expires -- there is no silent renewal. It is accepted on a default deployment without `EXPERIMENTAL_UI_LOGIN`, does not appear in the Keys UI, and cannot be rotated or revoked mid-session. A credential from `lite login --pkce` is the exception: it carries a refresh token, so the CLI renews the key shortly before it expires and `lite logout` revokes the refresh token on the proxy (see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. For a long-lived, rotatable, Keys-UI-visible credential, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY`.
The stored credential is a short-lived, per-session agent token, not a managed virtual key. It is scoped to the user and team you logged in as and inherits their models and budgets; spend is tracked against the shared team and user budgets rather than a separate per-session cap, so multiple logins or several concurrent agents all draw down the same allowance. It is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); re-run `lite login` to refresh it and pick up your latest team and user settings. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it, and once it is past 24 hours (or the client's `LITELLM_CLI_JWT_EXPIRATION_HOURS`) asks the proxy first and fails if the proxy rejects it. There is no silent renewal. It is accepted on a default deployment without `EXPERIMENTAL_UI_LOGIN`, does not appear in the Keys UI, and cannot be rotated or revoked mid-session. A credential from `lite login --pkce` is the exception: it carries a refresh token, so the CLI renews the key shortly before it expires and `lite logout` revokes the refresh token on the proxy (see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. For a long-lived, rotatable, Keys-UI-visible credential, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY`.
### Usage

View file

@ -506,7 +506,7 @@ To pin the model, pass the agent's own model flag (for example `lite claude --mo
The token minted by `lite login` is a short-lived, per-session agent credential, not a managed virtual key. It is scoped to the user and team you authenticated as, inherits that user's and team's models and budgets, and is enforced on the proxy exactly like a virtual key on the same team (guardrails, routing, logging, spend). Spend is tracked against the shared team and user budgets, so running several agents (or logging in more than once) does not hand each session its own separate budget; they all draw down the same team/user allowance. There is no separate per-session cap, so sustained agent use is not capped at a small chat-session limit.
The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while it's still fresh and fails once it expires -- there is no silent renewal, so a long-running session needs a fresh `lite login` once a day. `lite claude`, `lite codex`, `lite opencode`, and `lite pi` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. `lite login --pkce` is the exception to the daily re-login: it signs in through your system browser with OAuth authorization code and PKCE and stores a refresh token next to the key, so every `lite` command and `lite auth print-token` renew the key on their own shortly before it expires, `lite whoami` shows when the current key expires, and `lite logout` revokes the refresh token on the proxy (it needs a proxy that serves `/.well-known/litellm-cli-auth`; see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). When a renewal is refused, for example after a `lite logout` run from another copy of the credential, the command prints why on stderr and, once the key has run out, tells you to run `lite login --pkce` again. Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead.
The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it without asking the proxy for its first 24 hours, or `LITELLM_CLI_JWT_EXPIRATION_HOURS` if you set it on the client. After that, `print-token` and `lite whoami` ask the proxy whether it still accepts the token, and `print-token` fails once the proxy rejects it. There is no silent renewal, so a long-running session needs a fresh `lite login` each time the proxy's token lifetime runs out. If the proxy's tokens last less than 24 hours, set the same `LITELLM_CLI_JWT_EXPIRATION_HOURS` on the client so `print-token` stops printing the token once it runs out. `lite up` still starts a new login once the token is older than that same client window. It copies the token into Claude Code's settings as a static key that it never renews, so a fresh login gives Claude Code the proxy's full token lifetime instead of whatever an old token has left. `lite claude`, `lite codex`, `lite opencode`, and `lite pi` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. `lite login --pkce` is the exception to the re-login: it signs in through your system browser with OAuth authorization code and PKCE and stores a refresh token next to the key, so every `lite` command and `lite auth print-token` renew the key on their own shortly before it expires, `lite whoami` shows when the current key expires, and `lite logout` revokes the refresh token on the proxy (it needs a proxy that serves `/.well-known/litellm-cli-auth`; see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). When a renewal is refused, for example after a `lite logout` run from another copy of the credential, the command prints why on stderr and, once the key has run out, tells you to run `lite login --pkce` again. Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead.
### Route Every Claude Code Session Through the Proxy

View file

@ -13,7 +13,6 @@ from rich.console import Console
from rich.table import Table
from typing_extensions import NotRequired, ReadOnly, TypedDict, assert_never
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
from litellm.litellm_core_utils.cli_keyring import (
DISABLE_KEYRING_ENV_VAR,
SYSTEM_KEYRING,
@ -143,6 +142,9 @@ _TeamMapping: Final = TypeVar("_TeamMapping", bound=Mapping[str, object])
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True))
_PROXY_KEY_CHECK_TIMEOUT_SECONDS: Final = 3
_PROXY_REJECTED_KEY_STATUSES: Final = frozenset({401, 403})
KEYRING_INSTALL_HINT: Final = "pip install 'litellm[cli]'"
KEYRING_ENABLE_HINT: Final = "keyring --enable (or unset PYTHON_KEYRING_BACKEND)"
@ -1033,10 +1035,12 @@ def print_token(ctx: click.Context):
Designed to be used as Claude Code's `apiKeyHelper`
(https://docs.claude.com/en/docs/claude-code/settings): stdout must
contain only the token, so all diagnostics go to stderr. The token
expires after `LITELLM_CLI_JWT_EXPIRATION_HOURS` (default 24h); a
`lite login --pkce` token renews itself here first, and once a token
has expired for good, run the same `lite login` command again.
contain only the token, so all diagnostics go to stderr. A token younger
than `LITELLM_CLI_JWT_EXPIRATION_HOURS` (default 24h) is printed without
asking the proxy. An older one is checked against the proxy and refused
only if the proxy rejects it. A `lite login --pkce`
token renews itself here first, and once a token has expired for good,
run the same `lite login` command again.
"""
vault: Final = context_secret_vault(ctx)
token_data: Final = load_token(vault=vault)
@ -1055,7 +1059,7 @@ def print_token(ctx: click.Context):
sys.exit(1)
renews: Final = "refresh_token" in token_data
if not is_cli_token_fresh(token_data) and not renews:
if not renews and token_expired(token_data):
click.echo("Token expired. Run 'lite login' again.", err=True)
sys.exit(1)
@ -1111,8 +1115,32 @@ def whoami(ctx: click.Context):
expires_at: Final = token_data.get("expires_at")
if isinstance(expires_at, (int, float)):
click.echo(_key_expiry_line(expires_at, renews="refresh_token" in token_data))
elif age_hours > CLI_JWT_EXPIRATION_HOURS:
click.echo(f"Warning: Token is more than {CLI_JWT_EXPIRATION_HOURS} hours old and may have expired.")
elif token_expired(token_data):
click.echo("Token expired. Run 'lite login' again.")
def token_expired(token_data: Mapping[str, object]) -> bool:
if is_cli_token_fresh(token_data):
return False
if isinstance(token_data.get("expires_at"), (int, float)):
return True
return _proxy_rejects_key(token_data)
def _proxy_rejects_key(token_data: Mapping[str, object]) -> bool:
key: Final = token_data.get("key")
base_url: Final = token_data.get("base_url")
if not isinstance(key, str) or not isinstance(base_url, str) or not base_url:
return True
try:
response: Final = requests.get(
f"{base_url}/v1/models",
headers={"Authorization": f"Bearer {key}"},
timeout=_PROXY_KEY_CHECK_TIMEOUT_SECONDS,
)
except requests.RequestException:
return False
return response.status_code in _PROXY_REJECTED_KEY_STATUSES
def _key_expiry_line(expires_at: float, renews: bool) -> str:

View file

@ -160,9 +160,10 @@ async def create_gemini_agent(
-H "Authorization: Bearer sk-..." \\
-H "Content-Type: application/json" \\
-d '{
"name": "my-custom-slides-agent",
"base_agent": "waverunner",
"instructions": "You are a helpful assistant that creates slides.",
"id": "my-custom-slides-agent",
"base_agent": "antigravity-preview-09-2026",
"system_instruction": "You are a helpful assistant that creates slides.",
"agent_config": {"type": "antigravity", "model": "gemini-3.8-flash"},
"base_environment": {
"type": "remote",
"sources": [

View file

@ -45,11 +45,14 @@ from litellm.caching.redis_cache import log_redis_failure
from litellm.constants import DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE, INTERNAL_CALL_ORIGIN_METADATA_KEY
from litellm.cost_calculator import get_usage_object
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.audio_utils.utils import estimate_audio_tokens
from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_str_from_messages,
split_media_for_tpm,
)
from litellm.litellm_core_utils.ptu_pricing import is_ptu_cost_attribution_enabled
from litellm.litellm_core_utils.token_counter import offload_token_count
from litellm.litellm_core_utils.token_counter import estimate_image_tokens, offload_token_count
from litellm.llms.azure.ptu_capacity import normalized_tokens
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.auth_utils import (
@ -98,6 +101,7 @@ if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache
from litellm.router import Router
from litellm.types.agents import AgentResponse
from litellm.types.caching import RedisPipelineIncrementOperation
@ -135,6 +139,18 @@ class RateLimitedModel:
return requested_limit if requested_limit is not None else limits.get(self.group)
def _resolve_media_models(router: "Router | None", model: str, team_id: str | None) -> tuple[str, ...]:
if router is None:
return (model,)
return tuple(dict.fromkeys(router.resolved_litellm_models(model, team_id, use_base_model=True))) or (model,)
def _resolve_media_models_via_proxy_router(model: str, team_id: str | None) -> tuple[str, ...]:
from litellm.proxy.proxy_server import llm_router
return _resolve_media_models(llm_router, model, team_id)
def _resolve_model_group_alias_via_proxy_router(model: str) -> str | None:
from litellm.proxy.proxy_server import llm_router
@ -862,6 +878,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
time_provider: Callable[[], datetime] | None = None,
tag_rate_limit_resolver: TagRateLimitResolver = resolve_tag_rate_limits_from_db,
model_group_resolver: Callable[[str], str | None] = _resolve_model_group_alias_via_proxy_router,
media_model_resolver: Callable[[str, str | None], tuple[str, ...]] = _resolve_media_models_via_proxy_router,
ptu_team_ceiling_resolver: Callable[
[str, str], PTUTeamCeiling | None
] = _resolve_ptu_team_ceiling_via_proxy_router,
@ -872,6 +889,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
self._time_provider = time_provider or datetime.now
self._tag_rate_limit_resolver = tag_rate_limit_resolver
self._model_group_resolver = model_group_resolver
self._media_model_resolver = media_model_resolver
self._ptu_team_ceiling_resolver = ptu_team_ceiling_resolver
self._fail_closed_resolver = fail_closed_resolver
self._force_hash_tag_grouping_resolver = force_hash_tag_grouping_resolver
@ -1133,11 +1151,12 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
def _estimate_tokens_for_request(
self,
data: dict,
data: object,
model: str | None = None,
min_configured_tpm_limit: int | None = None,
call_type: str | None = None,
configured_output_tokens: int | None = None,
team_id: str | None = None,
) -> int:
"""
Estimate total tokens this request will consume so we can reserve them
@ -1164,6 +1183,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
min_configured_tpm_limit=min_configured_tpm_limit,
call_type=call_type,
configured_output_tokens=configured_output_tokens,
team_id=team_id,
media_model_resolver=self._media_model_resolver,
)
total_estimated: Final = estimated_input_tokens + max_tokens_estimate
@ -1184,6 +1205,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
call_type: str | None,
configured_output_tokens: int | None,
raw_estimate: int,
team_id: str | None = None,
) -> int:
"""The team PTU ceiling counts Azure normalized tokens, so its reservation weighs the
output budget the way the ceiling does instead of the raw sum the other scopes reserve."""
@ -1194,6 +1216,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
min_configured_tpm_limit=min_configured_tpm_limit,
call_type=call_type,
configured_output_tokens=configured_output_tokens,
team_id=team_id,
media_model_resolver=self._media_model_resolver,
)
normalized: Final = normalized_tokens(
ceiling, prompt_tokens=estimated_input_tokens, completion_tokens=max_tokens_estimate
@ -1206,6 +1230,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
min_configured_tpm_limit: int | None = None,
call_type: str | None = None,
configured_output_tokens: int | None = None,
include_media: bool = True,
team_id: str | None = None,
media_model_resolver: Callable[[str, str | None], tuple[str, ...]] | None = None,
) -> tuple[int, int]:
"""
Estimate input tokens and output (max_tokens) budget separately, so
@ -1229,10 +1256,12 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
floor entirely, so the reservation reflects what this tenant's model
actually emits rather than one constant shared by every tenant.
"""
if not isinstance(data, dict):
request_data: Final = as_str_mapping(data)
if not isinstance(data, dict) or request_data is None:
return 0, 0
request_model: Final = request_data.get("model")
translated_data: Final = self._translate_google_genai_native_request(data, call_type)
estimable_data: Final = translated_data if translated_data is not None else data
estimable_data: Final = translated_data if translated_data is not None else request_data
selected_fields: Final[tuple[object | None, object | None, object | None]] = (
(None, None, estimable_data.get("input"))
if call_type in RESPONSES_API_CALL_TYPES or call_type in EMBEDDING_API_CALL_TYPES
@ -1249,10 +1278,27 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
)
)
messages, prompt, raw_input = selected_fields
input_text: Final = (
original_input: Final = (
billable_input_since_newest_compaction(raw_input) if call_type in RESPONSES_API_CALL_TYPES else raw_input
)
media_source: Final = messages if messages is not None else original_input
projected, media = (
split_media_for_tpm(media_source)
if include_media and (isinstance(messages, list) or call_type in RESPONSES_API_CALL_TYPES)
else (original_input, ())
)
input_text: Final = projected if call_type in RESPONSES_API_CALL_TYPES else original_input
fallback_model: Final = request_model if isinstance(request_model, str) else None
resolved_models: Final = (
media_model_resolver(fallback_model or "", team_id)
if media and media_model_resolver is not None
else (fallback_model or "",)
)
media_tokens: Final = sum(
self._estimate_media_block_tokens(block, resolved_models, fallback_model) for block in media
)
total_chars: Final = (
len(get_str_from_messages(messages))
if isinstance(messages, list) and messages
@ -1267,7 +1313,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
else 0
)
estimated_input_tokens: Final = max(1, total_chars // DEFAULT_CHARS_PER_TOKEN) if total_chars > 0 else 0
estimated_input_tokens: Final = (
max(1, total_chars // DEFAULT_CHARS_PER_TOKEN) if total_chars > 0 else 0
) + media_tokens
explicit_max_tokens: Final = self._get_explicit_output_cap(data, call_type)
generates_no_output: Final = (
@ -1283,7 +1331,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
max_tokens_estimate: Final = (
0
if generates_no_output
or (explicit_max_tokens is None and total_chars == 0 and configured_output_tokens is None)
or (explicit_max_tokens is None and estimated_input_tokens == 0 and configured_output_tokens is None)
else explicit_max_tokens
if explicit_max_tokens is not None
else configured_output_tokens
@ -1293,6 +1341,24 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
return estimated_input_tokens, max_tokens_estimate * self.get_output_candidate_count(data, call_type)
@staticmethod
def _estimate_media_block_tokens(
block: Mapping[str, object], models: tuple[str, ...], fallback_model: str | None
) -> int:
if block.get("type") == "input_audio":
audio: Final = _callback_kwargs_mapping(block.get("input_audio"))
data: Final = audio.get("data")
return estimate_audio_tokens(data if isinstance(data, str) else None, models, fallback_model=fallback_model)
image: Final = _callback_kwargs_mapping(block.get("image_url"))
url: Final = image.get("url")
detail: Final = image.get("detail")
return estimate_image_tokens(
models,
url if isinstance(url, str) else "",
detail if isinstance(detail, str) else "auto",
fallback_model=fallback_model,
)
def _is_redis_cluster(self) -> bool:
"""
Check if the dual cache is using Redis cluster.
@ -3958,7 +4024,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
except Exception: # noqa: BLE001 # tokenizer failures degrade to the cheap estimate
if call_type in RERANK_API_CALL_TYPES and isinstance(selected_text, str):
return max(0, len(selected_text) // DEFAULT_CHARS_PER_TOKEN)
estimated_input_tokens, _ = self._estimate_input_and_output_tokens(data=data, call_type=call_type)
estimated_input_tokens, _ = self._estimate_input_and_output_tokens(
data=data, call_type=call_type, include_media=False
)
return estimated_input_tokens + audio_token_estimate
async def _reserve_project_io_tokens_or_raise(
@ -4003,6 +4071,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
data=data,
min_configured_tpm_limit=min_configured_otpm_limit,
call_type=call_type,
include_media=False,
)
raw_estimated_input_tokens: Final = await offload_token_count(self._estimate_precise_input_tokens)(
data=data, model=requested_model, call_type=call_type
@ -4360,12 +4429,13 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# Post-call reconciliation refunds the over-reservation
# delta when actual usage comes in below the floor.
estimated_tokens: Final = max(
self._estimate_tokens_for_request(
await offload_token_count(self._estimate_tokens_for_request)(
data=data,
model=requested_model,
min_configured_tpm_limit=min_configured_tpm_limit,
call_type=call_type,
configured_output_tokens=configured_output_tokens,
team_id=user_api_key_dict.team_id,
),
1,
)
@ -4383,13 +4453,14 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
)
capped_request: Final = _REQUEST_RATE_LIMIT_DATA.validate_python(data)
ptu_estimated_tokens: Final = self._estimate_ptu_tokens_for_request(
ptu_estimated_tokens: Final = await offload_token_count(self._estimate_ptu_tokens_for_request)(
ceiling=stash.ptu_ceiling,
data=capped_request,
min_configured_tpm_limit=min_configured_tpm_limit,
call_type=call_type,
configured_output_tokens=configured_output_tokens,
raw_estimate=estimated_tokens,
team_id=user_api_key_dict.team_id,
)
if stash.ptu_ceiling is not None and ptu_estimated_tokens > stash.ptu_ceiling.tpm_limit:
if not reuses_request_slot:

View file

@ -4145,6 +4145,34 @@
],
"default_model_placeholder": "gpt-3.5-turbo"
},
{
"provider": "ZeroGPU",
"provider_display_name": "ZeroGPU",
"litellm_provider": "zerogpu",
"credential_fields": [
{
"key": "api_base",
"label": "API Base",
"placeholder": "https://api.zerogpu.ai/v1",
"tooltip": null,
"required": false,
"field_type": "text",
"options": null,
"default_value": null
},
{
"key": "api_key",
"label": "API Key",
"placeholder": null,
"tooltip": null,
"required": true,
"field_type": "password",
"options": null,
"default_value": null
}
],
"default_model_placeholder": "zerogpu/gpt-oss-120b"
},
{
"provider": "CURSOR",
"provider_display_name": "Cursor",

View file

@ -256,7 +256,6 @@ REQUIRED_PRESENT_BODY_PARAMS_BY_ROUTE: Final[Mapping[str, tuple[str, ...]]] = Ma
"avideo_create_character": ("name", "video"),
"acreate_container": ("name",),
"aupload_container_file": ("file",),
"acreate_agent": ("name",),
"acreate_interaction": ("input",),
"acreate_eval": ("data_source_config", "testing_criteria"),
"acreate_run": ("data_source",),
@ -264,7 +263,7 @@ REQUIRED_PRESENT_BODY_PARAMS_BY_ROUTE: Final[Mapping[str, tuple[str, ...]]] = Ma
)
REQUIRED_ONE_OF_BODY_PARAMS_BY_ROUTE: Final[Mapping[str, tuple[str, str]]] = MappingProxyType(
{"acreate_interaction": ("model", "agent")}
{"acreate_interaction": ("model", "agent"), "acreate_agent": ("id", "name")}
)
JSON_OBJECT_ADAPTER: Final[TypeAdapter[dict[str, object]]] = TypeAdapter(dict[str, object])

View file

@ -1,8 +1,10 @@
import base64
import os
import re
from collections.abc import Callable, Collection, Iterable, Mapping, Sequence
from functools import reduce
from typing import Any, Final, Optional, TypeVar, Union, cast, get_type_hints, overload
from types import MappingProxyType
from typing import Any, Final, Optional, TypeVar, Union, cast, get_args, get_type_hints, overload
from pydantic import BaseModel, TypeAdapter
from typing_extensions import TypedDict as ExtraItemsTypedDict
@ -14,6 +16,7 @@ from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or
from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value, is_nested_path
from litellm.litellm_core_utils.get_provider_specific_headers import ProviderSpecificHeaderUtils
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig, ReasoningSummaryLevel
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
from litellm.types.llms.openai import (
AllMessageValues,
InputTokensDetails,
@ -29,6 +32,7 @@ from litellm.types.responses.main import DecodedResponseId
from litellm.types.utils import (
CacheCreationTokenDetails,
CompletionTokensDetailsWrapper,
ModelInfo,
PromptTokensDetailsWrapper,
ProviderSpecificHeader,
SpecialEnums,
@ -1382,3 +1386,98 @@ def auto_reasoning_summary(model: str, custom_llm_provider: object) -> Reasoning
provider, model.removeprefix(f"{provider}/")
)
return "detailed" if responses_config is None else responses_config.auto_reasoning_summary()
def opt_in_auto_reasoning_summary(model: str | None, custom_llm_provider: object) -> ReasoningSummaryLevel | None:
if not (litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"):
return None
return "detailed" if model is None else auto_reasoning_summary(model, custom_llm_provider)
def reasoning_with_summary_default(
reasoning: Mapping[str, object], summary_alias: object, model: str, custom_llm_provider: object
) -> Mapping[str, object]:
if "summary" in reasoning:
return {**reasoning}
summary: Final = (
summary_alias if summary_alias is not None else opt_in_auto_reasoning_summary(model, custom_llm_provider)
)
return {**reasoning} if summary is None else {**reasoning, "summary": summary}
_SUMMARY_VALUES: Final = frozenset((*get_args(ReasoningSummaryLevel), None))
def _openai_model_info(model: str) -> ModelInfo | None:
from litellm.utils import ModelInfoLookupMiss, lookup_model_info_for_registration
model_info: Final = lookup_model_info_for_registration(model.removeprefix("responses/"), "openai")
return None if isinstance(model_info, ModelInfoLookupMiss) else model_info
def _declared_values(values: Sequence[str] | None) -> frozenset[str | None] | None:
return None if values is None else frozenset(values)
def _openai_reasoning_field_values(model: str) -> Mapping[str, frozenset[str | None] | None]:
model_info: Final = _openai_model_info(model)
if model_info is None:
return MappingProxyType({"summary": _SUMMARY_VALUES, "mode": None, "context": None})
if not model_info.get("supports_reasoning"):
return MappingProxyType({})
return MappingProxyType(
{
"summary": _SUMMARY_VALUES,
"mode": _declared_values(model_info.get("reasoning_modes")),
"context": _declared_values(model_info.get("reasoning_contexts")),
}
)
def _openai_accepts_reasoning_field(
key: str, value: object, values_by_field: Mapping[str, frozenset[str | None] | None]
) -> bool:
if key == "effort":
return True
if key not in values_by_field:
return False
allowed: Final = values_by_field[key]
return allowed is None or (isinstance(value, str | None) and value in allowed)
def _openai_accepted_reasoning(reasoning: Mapping[str, object], model: str) -> Mapping[str, object]:
values_by_field: Final = _openai_reasoning_field_values(model)
return {
key: value for key, value in reasoning.items() if _openai_accepts_reasoning_field(key, value, values_by_field)
}
def _additional_drop_paths(additional_drop_params: object) -> tuple[str, ...]:
if additional_drop_params.__class__ is not list:
return ()
return tuple(path for path in iter_items(additional_drop_params) if isinstance(path, str))
def openai_bridge_reasoning(
*,
requested: Mapping[str, object],
effort_kept: bool,
summary_alias: object,
model: str,
drop_params: bool,
additional_drop_params: object,
) -> Mapping[str, object] | None:
drop_paths: Final = _additional_drop_paths(additional_drop_params)
if "reasoning_effort" in drop_paths:
return None
kept: Final = requested if effort_kept else {key: value for key, value in requested.items() if key != "effort"}
with_summary: Final = reasoning_with_summary_default(kept, summary_alias, model, "openai")
accepted: Final = (
_openai_accepted_reasoning(with_summary, model)
if drop_params and OpenAIGPTConfig.is_openai_catalog_model(model)
else {**with_summary}
)
remaining: Final = as_str_mapping(
_apply_nested_drop_params({"reasoning_effort": accepted}, list(drop_paths)).get("reasoning_effort")
)
return remaining or None

View file

@ -10364,28 +10364,38 @@ class Router:
model_info[field] = backend_value
@staticmethod
def _cost_map_backend_model(deployment: Deployment) -> str:
model_info_base_model: Final = deployment.model_info.base_model
def _cost_map_backend_model(deployment: Deployment | DeploymentTypedDict) -> str:
model_info_base_model: Final = (
deployment.model_info.base_model
if isinstance(deployment, Deployment)
else _configured_model_info(deployment).get("base_model")
)
if isinstance(model_info_base_model, str) and model_info_base_model:
return model_info_base_model
params_base_model: Final = deployment.litellm_params.get("base_model")
params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(
deployment.litellm_params.model_dump(exclude_none=True)
if isinstance(deployment, Deployment)
else deployment["litellm_params"]
)
params_base_model: Final = params.get("base_model")
if isinstance(params_base_model, str) and params_base_model:
return params_base_model
params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(deployment.litellm_params.model_dump(exclude_none=True))
model: Final = (
deployment.litellm_params.model
if isinstance(deployment, Deployment)
else deployment["litellm_params"].get("model", "")
)
invoked_model: Final = params.get("model_id")
if (
isinstance(invoked_model, str)
and invoked_model
and (
deployment.litellm_params.custom_llm_provider == "bedrock"
or deployment.litellm_params.model.startswith("bedrock/")
)
and (params.get("custom_llm_provider") == "bedrock" or model.startswith("bedrock/"))
):
profile_model: Final = invoked_model if invoked_model.startswith("bedrock/") else f"bedrock/{invoked_model}"
profile_info: Final = lookup_model_info_for_registration(model=profile_model, custom_llm_provider="bedrock")
if not isinstance(profile_info, ModelInfoLookupMiss):
return profile_model
return deployment.litellm_params.model
return model
@staticmethod
def _inherit_builtin_service_tier_pricing(
@ -13945,7 +13955,9 @@ class Router:
]
return named or self._get_wildcard_deployments(model_name=model_group)
def resolved_litellm_models(self, model_name: str, team_id: str | None = None) -> tuple[str, ...]:
def resolved_litellm_models(
self, model_name: str, team_id: str | None = None, *, use_base_model: bool = False
) -> tuple[str, ...]:
"""The provider model strings `model_name` can actually be served by on this proxy.
`get_model_list` composes every channel the request path itself uses (exact name,
@ -13958,6 +13970,12 @@ class Router:
deployment behind it - so the fallback for an empty result is the caller's policy,
never this function's.
"""
if use_base_model:
return tuple(
backend_model
for deployment in self.get_model_list(model_name=model_name, team_id=team_id) or ()
if (backend_model := self._cost_map_backend_model(deployment))
)
return tuple(
litellm_model
for deployment in self.get_model_list(model_name=model_name, team_id=team_id) or ()

View file

@ -46,6 +46,12 @@
- A failure after execution starts is terminal and never triggers fallback
- Creating an unstarted native coroutine acquires no clients or credentials and captures no execution context
# Native extension
- `_native` ships in every wheel and the sdist builds it, so it is always importable
- Import it with `from litellm.rust_bridge import _native` and use the attributes `_native.pyi` declares
- Never add a `None` branch, `getattr` default or stand-in class for a missing extension
# Verification
- Check cross-language behavior against a freshly installed extension, covering unstarted-call release, setup ordering, exception identity, sync and async streams, cancellation and GC

View file

@ -1,10 +1,5 @@
"""LiteLLM Rust bridge package."""
from litellm.rust_bridge.configuration import rust
from litellm.rust_bridge.loader import (
get_native_bridge,
native_bridge_available,
reset_native_bridge_cache,
)
__all__ = ["get_native_bridge", "native_bridge_available", "reset_native_bridge_cache", "rust"]
__all__ = ["rust"]

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Final, Generic, TypeVar
from litellm.rust_bridge.loader import get_native_bridge
from litellm.rust_bridge import _native
BindingT = TypeVar("BindingT")
@ -18,32 +18,24 @@ _UNSET: Final = _Unset()
class NativeBinding(Generic[BindingT]):
"""Resolve one native attribute with an explicit, resettable test override."""
def __init__(self, attribute: str, *, validate: Callable[[object], BindingT | None]) -> None:
def __init__(
self, attribute: str, *, validate: Callable[[object], BindingT | None], source: object = _native
) -> None:
self._attribute: Final = attribute
self._validate: Final = validate
self._source: Final = source
self._override: BindingT | None | _Unset = _UNSET
def load(self) -> BindingT | None:
if not isinstance(self._override, _Unset):
return self._override
native: Final = get_native_bridge()
if native is None:
return None
return self._validate(getattr(native, self._attribute, None))
return self._validate(getattr(self._source, self._attribute, None))
def with_source(self, source: object) -> NativeBinding[BindingT]:
return NativeBinding(self._attribute, validate=self._validate, source=source)
def override(self, value: BindingT | None) -> None:
self._override = value
def reset(self) -> None:
self._override = _UNSET
def native_exception_types() -> tuple[type[BaseException], type[BaseException]] | None:
native: Final = get_native_bridge()
if native is None:
return None
declined: Final = getattr(native, "RustBridgeDeclined", None)
upstream: Final = getattr(native, "RustUpstreamError", None)
if not isinstance(declined, type) or not isinstance(upstream, type):
return None
return declined, upstream

View file

@ -1,12 +1,10 @@
import os
from collections.abc import Awaitable, Mapping, Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Final, Protocol, runtime_checkable
from pydantic import ConfigDict, TypeAdapter
from typing import Final
from litellm.constants import DEFAULT_AGENT_TRACING_RETENTION_DAYS, DEFAULT_CLICKHOUSE_DATABASE
from litellm.rust_bridge.loader import get_native_bridge
from litellm.rust_bridge import _native
@dataclass(frozen=True, slots=True, repr=False)
@ -16,27 +14,6 @@ class SpendStorageConfig:
retention_days: int = DEFAULT_AGENT_TRACING_RETENTION_DAYS
class NativeSpendConfig(Protocol):
def __init__(self, database: str, url: str, retention_days: int) -> None: ...
class NativeSpendStorage(Protocol):
def __init__(self, config: NativeSpendConfig) -> None: ...
def ensure_schema(self) -> Awaitable[None]: ...
def insert_rows(self, rows: Sequence[Mapping[str, object]]) -> Awaitable[None]: ...
@runtime_checkable
class NativeSpendBridge(Protocol):
NativeClickHouseSpendConfig: type[NativeSpendConfig]
NativeClickHouseSpendStorage: type[NativeSpendStorage]
_NATIVE: Final[TypeAdapter[NativeSpendBridge]] = TypeAdapter(
NativeSpendBridge, config=ConfigDict(arbitrary_types_allowed=True)
)
def spend_storage_config(environ: Mapping[str, str] = os.environ) -> SpendStorageConfig:
url: Final = environ.get("CLICKHOUSE_URL")
if not url:
@ -53,12 +30,8 @@ def spend_storage_config(environ: Mapping[str, str] = os.environ) -> SpendStorag
class ClickHouseSpendStorage:
def __init__(self, config: SpendStorageConfig) -> None:
bridge: Final = get_native_bridge()
if bridge is None:
raise RuntimeError("ClickHouse spend logging requires the Rust extension")
native: Final = _NATIVE.validate_python(bridge)
self._native: Final = native.NativeClickHouseSpendStorage(
native.NativeClickHouseSpendConfig(config.database, config.url, config.retention_days)
self._native: Final = _native.NativeClickHouseSpendStorage(
_native.NativeClickHouseSpendConfig(config.database, config.url, config.retention_days)
)
async def ensure_schema(self) -> None:

View file

@ -11,32 +11,22 @@ site, so the runtime can never start there. Workers forked from it are unaffecte
from __future__ import annotations
from collections.abc import Callable
from typing import Final
from litellm.rust_bridge.loader import get_native_bridge
from litellm.rust_bridge import _native
class NativeStateStartedBeforeFork(RuntimeError):
pass
class _NeverRaised(RuntimeError):
"""Stands in for a native exception when the extension is unavailable or predates it."""
ForkedAfterNativeRuntimeStarted: Final = _native.ForkedAfterNativeRuntimeStarted
ProcessReservedForForking: Final = _native.ProcessReservedForForking
_native: Final = get_native_bridge()
ForkedAfterNativeRuntimeStarted: Final[type[RuntimeError]] = getattr(
_native, "ForkedAfterNativeRuntimeStarted", _NeverRaised
)
ProcessReservedForForking: Final[type[RuntimeError]] = getattr(_native, "ProcessReservedForForking", _NeverRaised)
def reserve_process_for_forking(where: str) -> None:
def reserve_process_for_forking(where: str, reserve: Callable[[], None] = _native.reserve_process_for_forking) -> None:
"""Forbid native routes in this process. Raises if one already ran here."""
native: Final = get_native_bridge()
reserve: Final = getattr(native, "reserve_process_for_forking", None)
if not callable(reserve):
return
try:
reserve()
except RuntimeError as error:

View file

@ -1,35 +0,0 @@
"""Loader for the packaged LiteLLM Rust extension."""
from __future__ import annotations
from types import ModuleType
from typing import Final
_BRIDGE_SENTINEL: Final = object()
_cached_bridge: ModuleType | None | object = _BRIDGE_SENTINEL
def get_native_bridge() -> ModuleType | None:
"""Return the packaged Rust extension, or ``None`` when unavailable."""
global _cached_bridge
if _cached_bridge is not _BRIDGE_SENTINEL:
return _cached_bridge if isinstance(_cached_bridge, ModuleType) else None
try:
from litellm.rust_bridge import _native
except ImportError:
_cached_bridge = None
return None
_cached_bridge = _native
return _native
def reset_native_bridge_cache() -> None:
"""Forget the cached extension so the next lookup reimports it from disk."""
global _cached_bridge
_cached_bridge = _BRIDGE_SENTINEL
def native_bridge_available() -> bool:
"""Whether the packaged Rust extension is importable."""
return get_native_bridge() is not None

View file

@ -2,32 +2,33 @@
from __future__ import annotations
from collections.abc import Awaitable
from dataclasses import dataclass
from typing import Final, Protocol
import httpx
from websockets.exceptions import ConnectionClosedOK
from litellm.rust_bridge import _native
from litellm.rust_bridge.host.timeouts import timeout_to_seconds
from litellm.rust_bridge.loader import get_native_bridge
class RustResponsesWebSocket(Protocol):
async def send_text(self, text: str) -> None: ...
def send_text(self, text: str) -> Awaitable[None]: ...
async def recv_text(self) -> str | None: ...
def recv_text(self) -> Awaitable[str | None]: ...
async def close(self) -> None: ...
def close(self) -> Awaitable[None]: ...
class RustResponsesWebSocketConnection(Protocol):
@classmethod
async def connect(
def connect(
cls,
url: str,
headers: dict[str, str],
timeout_seconds: float | None,
) -> RustResponsesWebSocket: ...
) -> Awaitable[RustResponsesWebSocket]: ...
class _Unset:
@ -53,16 +54,10 @@ def set_rust_responses_websocket(
_STATE.connection = connection
def load_rust_responses_websocket() -> RustResponsesWebSocketConnection | None:
def load_rust_responses_websocket() -> RustResponsesWebSocketConnection:
if _STATE.connection is not None:
return _STATE.connection
native_bridge: Final = get_native_bridge()
if native_bridge is None:
return None
connection_type: Final[RustResponsesWebSocketConnection | None] = getattr(
native_bridge, "ResponsesWebSocketConnection", None
)
return connection_type
return _native.ResponsesWebSocketConnection
class _ConnectionAdapter:
@ -89,8 +84,6 @@ async def connect(
timeout: float | httpx.Timeout | None,
) -> _ConnectionAdapter | None:
connection_type: Final = load_rust_responses_websocket()
if connection_type is None:
return None
try:
connection: Final = await connection_type.connect(
url=url,

View file

@ -7,7 +7,8 @@ from typing import Final, Generic, NoReturn, TypeAlias, TypeVar
from typing_extensions import assert_never
from litellm.exceptions import APIError
from litellm.rust_bridge.bindings import NativeBinding, native_exception_types
from litellm.rust_bridge import _native
from litellm.rust_bridge.bindings import NativeBinding
from litellm.rust_bridge.catalog import RouteContext, Rules, decision
from litellm.rust_bridge.configuration import Decision
from litellm.rust_bridge.response_metadata import mark_rust_response
@ -153,15 +154,11 @@ def attempt(
) -> RustAttempt[ResultT]:
if native_call is None:
return RustUnavailable()
exceptions: Final = native_exception_types()
if exceptions is None:
return RustHandled(adapt(native_call()))
declined, upstream = exceptions
try:
value: Final = native_call()
except declined as error:
except _native.RustBridgeDeclined as error:
return RustDeclined(reason=_decline_reason(error))
except upstream as error:
except _native.RustUpstreamError as error:
_raise_upstream(error, context)
return RustHandled(adapt(value))
@ -174,15 +171,11 @@ async def aattempt(
) -> RustAttempt[ResultT]:
if native_call is None:
return RustUnavailable()
exceptions: Final = native_exception_types()
if exceptions is None:
return RustHandled(adapt(await native_call()))
declined, upstream = exceptions
try:
value: Final = await native_call()
except declined as error:
except _native.RustBridgeDeclined as error:
return RustDeclined(reason=_decline_reason(error))
except upstream as error:
except _native.RustUpstreamError as error:
_raise_upstream(error, context)
return RustHandled(adapt(value))

View file

@ -1,20 +1,9 @@
import json
from typing import Final, Protocol, runtime_checkable
from typing import Final
from pydantic import ConfigDict, TypeAdapter
from typing_extensions import ReadOnly, TypedDict
from litellm.rust_bridge.loader import get_native_bridge
@runtime_checkable
class NativeOtlpError(Protocol):
def trace_encode_error(self, message: str) -> bytes: ...
_NATIVE: Final[TypeAdapter[NativeOtlpError]] = TypeAdapter(
NativeOtlpError, config=ConfigDict(arbitrary_types_allowed=True)
)
from litellm.rust_bridge import _native
class OTLPError(TypedDict):
@ -22,8 +11,7 @@ class OTLPError(TypedDict):
def encode_error(message: str) -> bytes:
native: Final = get_native_bridge()
return b"" if native is None else _NATIVE.validate_python(native).trace_encode_error(message)
return _native.trace_encode_error(message)
def encode_otlp_response(content_type: str | None, error: str | None = None) -> tuple[bytes, str]:

View file

@ -3,7 +3,7 @@ from datetime import datetime
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, TypeAlias
from urllib.parse import urlsplit
from pydantic import ConfigDict, Field, PrivateAttr, StrictInt, field_validator
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, StrictInt, field_validator
from typing_extensions import ReadOnly, Required, TypedDict
from litellm.types.llms.base import LiteLLMBaseModel, LiteLLMPydanticObjectBase
@ -343,6 +343,18 @@ class ListAgentsResponse(LiteLLMBaseModel):
agents: list[AgentResponse]
class AgentCreateRequest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
id: str
base_agent: str | None = None
agent_config: Mapping[str, object] | None = None
system_instruction: str | None = None
base_environment: str | Mapping[str, object] | None = None
description: str | None = None
tools: Sequence[Mapping[str, object]] | None = None
class AgentCreateResponse(LiteLLMPydanticObjectBase):
"""
Response from a provider-side agent creation or get call (e.g. Gemini v1beta/agents).

View file

@ -225,6 +225,8 @@ class ProviderSpecificModelInfo(TypedDict, total=False):
supports_xhigh_reasoning_effort: bool | None
supports_max_reasoning_effort: bool | None
reasoning_effort_levels: ReadOnly[Sequence[str] | None]
reasoning_modes: ReadOnly[Sequence[str] | None]
reasoning_contexts: ReadOnly[Sequence[str] | None]
default_reasoning_effort: ReadOnly[Literal["none", "minimal", "low", "medium", "high", "xhigh"] | None]
supports_output_config: bool | None
supports_image_size: bool | None
@ -4586,6 +4588,7 @@ class LlmProviders(str, Enum):
PRISM = "prism"
OPENINFER = "openinfer"
INFERENCEAPIS = "inferenceapis"
ZEROGPU = "zerogpu"
DARKBLOOM = "darkbloom"
META = "meta"
SAIL = "sail"

View file

@ -7000,6 +7000,8 @@ def get_model_info_helper(
supports_xhigh_reasoning_effort=_model_info.get("supports_xhigh_reasoning_effort", None),
supports_max_reasoning_effort=_model_info.get("supports_max_reasoning_effort", None),
reasoning_effort_levels=_model_info.get("reasoning_effort_levels", None),
reasoning_modes=_model_info.get("reasoning_modes", None),
reasoning_contexts=_model_info.get("reasoning_contexts", None),
default_reasoning_effort=_model_info.get("default_reasoning_effort", None),
bedrock_output_config_effort_ceiling=_model_info.get("bedrock_output_config_effort_ceiling", None),
bedrock_converse_supports_strict_tools=_model_info.get("bedrock_converse_supports_strict_tools", None),
@ -9612,18 +9614,19 @@ class ProviderConfigManager:
return litellm.HostedVLLMDecisionsConfig()
if provider == LlmProviders.LM_STUDIO:
return litellm.LMStudioDecisionsConfig()
if provider in (LlmProviders.OLLAMA, LlmProviders.OLLAMA_CHAT):
return litellm.OllamaDecisionsConfig()
if provider == LlmProviders.LAYA:
return litellm.LayaDecisionsConfig()
if provider == LlmProviders.BESPOKE:
return litellm.BespokeDecisionsConfig()
if provider in (LlmProviders.OPENAI, LlmProviders.AZURE):
if provider in (LlmProviders.OPENAI, LlmProviders.AZURE, LlmProviders.AZURE_AI):
configs: Final = {
LlmProviders.OPENAI: litellm.OpenAIDecisionsConfig,
LlmProviders.AZURE: litellm.AzureDecisionsConfig,
LlmProviders.AZURE_AI: litellm.AzureAIDecisionsConfig,
}
return configs[provider]()
if provider == LlmProviders.AZURE_AI:
return litellm.AzureAIDecisionsConfig()
return None
@staticmethod

File diff suppressed because it is too large Load diff

View file

@ -1225,7 +1225,7 @@
"responses": true,
"text_completion": true,
"embeddings": true,
"image_generations": false,
"image_generations": true,
"image_edits": false,
"audio_transcriptions": false,
"audio_speech": false,
@ -4437,6 +4437,32 @@
"generateContent": true
}
},
"zerogpu": {
"display_name": "ZeroGPU (`zerogpu`)",
"url": "https://docs.litellm.ai/docs/providers/zerogpu",
"endpoints": {
"chat_completions": true,
"responses": true,
"text_completion": true,
"embeddings": false,
"image_generations": false,
"image_edits": false,
"audio_transcriptions": false,
"audio_speech": false,
"rerank": false,
"ocr": false,
"search": false,
"moderations": false,
"realtime": false,
"video_generations": false,
"batches": false,
"messages": true,
"interactions": true,
"systemone": false,
"a2a": true,
"generateContent": true
}
},
"ragflow": {
"display_name": "RAGFlow (`ragflow`)",
"url": "https://docs.litellm.ai/docs/providers/ragflow",

View file

@ -401,7 +401,7 @@ markers = [
"asyncio: mark test as an asyncio test",
"limit_leaks: mark test with memory limit for leak detection (e.g., '40 MB')",
"no_parallel: mark test to run sequentially (not in parallel) - typically for memory measurement tests",
"requires_rust_extension: public Python contract requiring an enabled, compiled Rust extension",
"requires_rust_extension: public Python contract tests that run against the release-built wheel",
]
filterwarnings = [
# Suppress Pydantic serializer warnings from mock server responses (non-critical for memory tests)

View file

@ -8,6 +8,8 @@ the very class of undeclared-dependency bug this guards against.
import argparse
import importlib.util
from importlib.machinery import EXTENSION_SUFFIXES
from pathlib import Path
import re
import sys
import traceback
@ -89,6 +91,18 @@ def check_import() -> str:
return f"imported {distribution} {sdk_version}"
def check_native_extension() -> str:
import litellm
from litellm.rust_bridge import _native
native_path: Final = Path(_native.__file__).resolve()
package_path: Final = Path(litellm.__file__).resolve().parent
_require(native_path.parent == package_path / "rust_bridge", "native extension is outside the SDK package")
_require(any(str(native_path).endswith(suffix) for suffix in EXTENSION_SUFFIXES), "native module is not compiled")
_require(_native.trace_encode_error("ping") == b"\x12\x04ping", "native OTLP error encoding failed")
return f"loaded and exercised {native_path}"
def check_completion() -> str:
import litellm
@ -230,6 +244,7 @@ def check_bedrock_credential_resolution() -> str:
CHECKS: tuple[tuple[str, Callable[[], str]], ...] = (
("environment is base-only", check_environment_is_base_only),
("import litellm", check_import),
("native extension", check_native_extension),
("optional MCP installation guidance", check_mcp_install_guidance),
("chat completion", check_completion),
("chat completion with a function tool", check_completion_with_function_tool),
@ -250,8 +265,11 @@ def _run(check: Callable[[], str]) -> tuple[bool, str]:
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--profile", choices=("legacy", "core", "dependencies"), default="legacy")
parser.add_argument("--profile", choices=("legacy", "core", "dependencies", "native"), default="legacy")
profile = parser.parse_args().profile
if profile == "native":
print(check_native_extension())
return 0
for module in ("boto3", "botocore", "tokenizers", "huggingface_hub"):
present = importlib.util.find_spec(module) is not None
_require(present == (profile != "core"), f"{profile}: unexpected presence of {module}: {present}")

View file

@ -254,8 +254,13 @@ _ROUTES: Final[dict[str, _Route]] = {
"acreate_agent": (
"/v1beta/agents",
"/v1beta/agents",
("name",),
{"name": "agent", "base_agent": "waverunner", "instructions": "You are a helpful assistant."},
("id",),
{
"id": "agent",
"base_agent": "antigravity-preview-09-2026",
"system_instruction": "You are a helpful assistant.",
"agent_config": {"type": "antigravity", "model": "gemini-3.8-flash"},
},
"gemini/gemini-2.5-flash",
),
"acreate_interaction": (
@ -1250,11 +1255,7 @@ def test_valid_agent_creation_reaches_upstream(gateway: Gateway) -> None:
_assert_scripted_response("acreate_agent", caller)
matches: Final = _observed(_Observations(gateway.upstream_url), identity)
outbound: Final = object_value(matches[0]["body"])
assert outbound == {
"name": "agent",
"base_agent": "waverunner",
"instructions": "You are a helpful assistant.",
}, matches
assert outbound == body, matches
def test_valid_speech_returns_binary_audio_and_reaches_upstream(gateway: Gateway) -> None:

View file

@ -1,7 +1,7 @@
import json
import math
import uuid
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Final
@ -140,6 +140,38 @@ _PROVIDERS: Final = (
"databricks/databricks-openjev-qwen35-4b",
),
)
_LOCAL_PROVIDERS: Final = (
pytest.param(
_Provider("ollama", "ollama/d3:4b", "/v1/systemone", "d3:4b", None, False, None), id="regression-ollama"
),
pytest.param(
_Provider("ollama_chat", "ollama_chat/d3:4b", "/v1/systemone", "d3:4b", None, False, None),
id="regression-ollama_chat",
),
pytest.param(
_Provider("hosted_vllm", "hosted_vllm/d3:4b", "/v1/systemone", "d3:4b", None, False, None),
id="guard-hosted_vllm",
),
pytest.param(
_Provider("lm_studio", "lm_studio/d3:4b", "/v1/systemone", "d3:4b", None, False, None),
id="guard-lm_studio",
),
)
_LOCAL_STATE: Final = "I was charged twice for order 4417."
_LOCAL_QUESTIONS: Final[Mapping[str, JsonValue]] = {
"billing": {"type": "noul", "instructions": "Is this a billing issue?"}
}
_LOCAL_ANSWERS: Final[Mapping[str, JsonValue]] = {"billing": {"type": "noul", "noul": 0.91}}
_LOCAL_OPENAI_QUESTIONS: Final[Sequence[JsonValue]] = (
{"type": "predicate", "name": "billing", "instructions": "Is this a billing issue?"},
)
_LOCAL_OPENAI_ANSWERS: Final[Sequence[JsonValue]] = ({"type": "predicate", "name": "billing", "probability": 0.91},)
_LOCAL_OPENAI_USAGE: Final[Mapping[str, JsonValue]] = {
**_USAGE,
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0},
"total_tokens": 370,
}
_PERPLEXITY: Final = _PROVIDERS[0]
_OPENROUTER: Final = next(provider for provider in _PROVIDERS if provider.name == "openrouter")
_OPENROUTER_CHAT_MODEL: Final = "openrouter/openai/gpt-5-mini"
@ -230,6 +262,26 @@ def _upstream_calls(gateway: Gateway, handle: ScenarioHandle) -> list[dict[str,
return _calls_to(_observed_requests(gateway), handle)
def _sdk_inference_call(gateway: Gateway, handle: ScenarioHandle, provider: _Provider) -> dict[str, JsonValue]:
observed: Final = _upstream_calls(gateway, handle)
inference_path: Final = f"/{handle.scenario_id}/v1/systemone"
(call,) = tuple(request for request in observed if request["path"] == inference_path)
metadata_calls: Final = tuple(request for request in observed if request["path"] != inference_path)
assert metadata_calls in (
(),
(
{
"path": f"{inference_path}/api/show",
"authorization": "",
"body": {"name": provider.body_model},
"method": "POST",
"api_key": "",
},
),
), observed
return call
def _spend_row(call_id: str) -> dict[str, JsonValue]:
rows: Final = eventually(lambda: read_rows(_SPEND_QUERY, (call_id,)), lambda found: len(found) == 1, seconds=70)
return rows[0]
@ -345,6 +397,174 @@ def test_each_provider_gets_its_own_path_key_and_body_and_is_billed(gateway: Gat
assert math.isclose(_number(row["spend"]), expected_spend, rel_tol=1e-9), row
@pytest.fixture(autouse=True)
def _without_ambient_ollama_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
@pytest.mark.parametrize("provider", _LOCAL_PROVIDERS)
@pytest.mark.parametrize("route", ("/v1/systemone", "/v1/decisions"), ids=("systemone", "decisions"))
@pytest.mark.parametrize(
("base_suffix", "api_key"),
(
pytest.param("", None, id="root-no-key"),
pytest.param("/", _API_KEY, id="root-slash-key"),
pytest.param("/v1", None, id="v1-no-key"),
pytest.param("/v1/", _API_KEY, id="v1-slash-key"),
),
)
def test_local_decisions_routes_preserve_wire_auth_usage_and_spend(
gateway: Gateway, provider: _Provider, route: str, base_suffix: str, api_key: str | None
) -> None:
native_reply: Final[Mapping[str, JsonValue]] = {
"model": provider.body_model,
"answers": dict(_LOCAL_ANSWERS),
"usage": _USAGE,
}
request_body: Final[Mapping[str, JsonValue]] = (
{"state": _LOCAL_STATE, "questions": dict(_LOCAL_QUESTIONS)}
if route == "/v1/systemone"
else {"input": _LOCAL_STATE, "questions": list(_LOCAL_OPENAI_QUESTIONS)}
)
expected_reply: Final = (
native_reply
if route == "/v1/systemone"
else {"model": provider.body_model, "answers": list(_LOCAL_OPENAI_ANSWERS), "usage": dict(_LOCAL_OPENAI_USAGE)}
)
with gateway.scenario() as scenario:
handle: Final = _register(scenario, dict(native_reply))
model: Final = scenario.model(
model=provider.model, api_base=f"{handle.api_base()}{base_suffix}", api_key=api_key
)
response: Final = gateway.request("POST", route, {"model": model, **request_body})
assert response.status_code == 200, response.text
assert response.json() == expected_reply
assert response.headers["x-litellm-model-group"] == model
assert float(response.headers.get("x-litellm-response-cost", "0")) == 0.0
(call,) = _upstream_calls(gateway, handle)
assert (call["path"], call["authorization"], call["body"]) == (
f"/{handle.scenario_id}/v1/systemone",
f"Bearer {api_key}" if api_key else "",
{"model": provider.body_model, "state": _LOCAL_STATE, "questions": _LOCAL_QUESTIONS},
)
row: Final = _spend_row(response.headers["x-litellm-call-id"])
assert {key: value for key, value in row.items() if key != "request_tags"} == {
"spend": 0.0,
"status": "success",
"call_type": "asystemone" if route == "/v1/systemone" else "adecisions",
"model_group": model,
"custom_llm_provider": provider.name,
"api_base": f"{handle.api_base()}/v1/systemone",
"prompt_tokens": _USAGE["input_tokens"],
"completion_tokens": _USAGE["output_tokens"],
}
@pytest.mark.parametrize("provider", _LOCAL_PROVIDERS)
@pytest.mark.parametrize("asynchronous", (False, True), ids=("sync", "async"))
async def test_local_systemone_sdk_preserves_native_wire(
gateway: Gateway, provider: _Provider, asynchronous: bool
) -> None:
with gateway.scenario() as scenario:
handle: Final = _register(scenario, _answer_body(provider))
response: Final = (
await litellm.asystemone(
model=provider.model, state=_STATE, questions=_SDK_QUESTIONS, api_base=f"{handle.api_base()}/v1/"
)
if asynchronous
else litellm.systemone(
model=provider.model, state=_STATE, questions=_SDK_QUESTIONS, api_base=handle.api_base()
)
)
assert response.model_dump(mode="json") == _answer_body(provider)
call: Final = _sdk_inference_call(gateway, handle, provider)
assert (call["path"], call["authorization"], call["body"]) == (
f"/{handle.scenario_id}/v1/systemone",
"",
{"model": provider.body_model, "state": _STATE, "questions": _QUESTIONS},
)
@pytest.mark.parametrize("provider", _LOCAL_PROVIDERS)
@pytest.mark.parametrize("asynchronous", (False, True), ids=("sync", "async"))
async def test_local_decisions_sdk_translates_openai_format(
gateway: Gateway, provider: _Provider, asynchronous: bool
) -> None:
questions: Final = TypeAdapter(list[dict[str, object]]).validate_python(_LOCAL_OPENAI_QUESTIONS)
native_reply: Final[Mapping[str, JsonValue]] = {
"model": provider.body_model,
"answers": dict(_LOCAL_ANSWERS),
"usage": _USAGE,
}
with gateway.scenario() as scenario:
handle: Final = _register(scenario, dict(native_reply))
response: Final = (
await litellm.adecisions(
model=provider.model, input=_LOCAL_STATE, questions=questions, api_base=f"{handle.api_base()}/v1/"
)
if asynchronous
else litellm.decisions(
model=provider.model, input=_LOCAL_STATE, questions=questions, api_base=handle.api_base()
)
)
assert response.model_dump(mode="json") == {
"model": provider.body_model,
"answers": list(_LOCAL_OPENAI_ANSWERS),
"usage": dict(_LOCAL_OPENAI_USAGE),
}
call: Final = _sdk_inference_call(gateway, handle, provider)
assert (call["path"], call["authorization"], call["body"]) == (
f"/{handle.scenario_id}/v1/systemone",
"",
{"model": provider.body_model, "state": _LOCAL_STATE, "questions": _LOCAL_QUESTIONS},
)
@pytest.mark.parametrize("provider", _LOCAL_PROVIDERS[:2])
@pytest.mark.parametrize(
("environment_base", "api_key", "environment_key", "authorization"),
(
pytest.param(True, None, _ENV_KEY, f"Bearer {_ENV_KEY}", id="environment-base-and-key"),
pytest.param(False, _API_KEY, _ENV_KEY, f"Bearer {_API_KEY}", id="explicit-overrides-environment"),
pytest.param(False, "fake-api-key", _ENV_KEY, "", id="fake-key-suppresses-environment"),
pytest.param(False, None, None, "", id="unrelated-keys-not-forwarded"),
),
)
def test_ollama_decisions_sdk_respects_environment_and_explicit_auth(
gateway: Gateway,
monkeypatch: pytest.MonkeyPatch,
provider: _Provider,
environment_base: bool,
api_key: str | None,
environment_key: str | None,
authorization: str,
) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "synthetic-unrelated-openai-key")
monkeypatch.setenv("LITELLM_API_KEY", "synthetic-unrelated-litellm-key")
if environment_key is not None:
monkeypatch.setenv("OLLAMA_API_KEY", environment_key)
with gateway.scenario() as scenario:
handle: Final = _register(scenario, _answer_body(provider))
unused: Final = _register(scenario, _answer_body(provider))
monkeypatch.setenv("OLLAMA_API_BASE", handle.api_base() if environment_base else unused.api_base())
response: Final = litellm.systemone(
model=provider.model,
state=_STATE,
questions=_SDK_QUESTIONS,
api_base=None if environment_base else f"{handle.api_base()}/v1/",
api_key=api_key,
)
assert response.model_dump(mode="json") == _answer_body(provider)
call: Final = _sdk_inference_call(gateway, handle, provider)
assert (call["path"], call["authorization"], call["body"]) == (
f"/{handle.scenario_id}/v1/systemone",
authorization,
{"model": provider.body_model, "state": _STATE, "questions": _QUESTIONS},
)
assert _upstream_calls(gateway, unused) == []
def test_test_connection_evaluation_mode_uses_typesafe_decisions_path(gateway: Gateway) -> None:
provider: Final = _PROVIDERS[1]
with gateway.scenario() as scenario:

View file

@ -75,9 +75,9 @@ def test_agents_forwards_caller_supplied_key_to_chosen_api_base(gateway: Gateway
assert request.headers["x-goog-api-key"] == "synthetic-caller-key"
assert _ENV_GEMINI_KEY not in request.headers.values()
assert json.loads(request.body) == {
"name": _AGENT_NAME,
"id": _AGENT_NAME,
"base_agent": "waverunner",
"instructions": "make slides",
"system_instruction": "make slides",
}
return Reply(body=json.dumps(_AGENT_REPLY).encode())

View file

@ -1,6 +1,4 @@
from collections.abc import AsyncIterator, Generator
from importlib import import_module
from typing import Final
import pytest
import pytest_asyncio
@ -23,16 +21,6 @@ def recording_server() -> Generator[RecordingServer]:
yield server
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
try:
import_module("litellm.rust_bridge._native")
except ImportError:
skip: Final = pytest.mark.skip(reason="requires a compiled litellm.rust_bridge._native extension")
for item in items:
if "test_litellm_rust" in item.path.parts:
item.add_marker(skip)
@pytest.fixture
def isolated_azure_auth(monkeypatch: pytest.MonkeyPatch) -> None:
for name in (

View file

@ -578,8 +578,14 @@ async def test_unknown_provider_is_rejected_before_http(respx_mock: respx.MockRo
@pytest.mark.asyncio
async def test_provider_without_decisions_support_is_rejected_before_http(respx_mock: respx.MockRouter) -> None:
with pytest.raises(litellm.BadRequestError, match=r"Unknown Decisions provider 'anthropic'\. Supported providers"):
async def test_provider_without_decisions_support_is_rejected_before_http(
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
with pytest.raises(
litellm.BadRequestError, match=r"Unknown Decisions provider 'anthropic'\. Supported providers"
) as error:
await litellm.asystemone(
model="anthropic/claude-sonnet-4-5",
state="review",
@ -587,6 +593,8 @@ async def test_provider_without_decisions_support_is_rejected_before_http(respx_
api_key="caller-key",
)
supported: Final = str(error.value).split("Supported providers: ", 1)[1].split(", ")
assert {"ollama", "ollama_chat"} <= set(supported)
assert len(respx_mock.calls) == 0
@ -1536,6 +1544,8 @@ _PROVIDERS_WHOSE_CHAT_MODELS_ALSO_DECIDE: Final = frozenset(
LlmProviders.OPENROUTER.value,
LlmProviders.HOSTED_VLLM.value,
LlmProviders.LM_STUDIO.value,
LlmProviders.OLLAMA.value,
LlmProviders.OLLAMA_CHAT.value,
}
)
_DEDICATED_DECIDER_PROVIDERS: Final[tuple[str, ...]] = tuple(
@ -3911,6 +3921,185 @@ async def test_native_decisions_distinguishes_missing_usage_from_reported_zero(
assert (expected == 0) == (usage["input_tokens"] == usage["output_tokens"] == 0)
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ("ollama", "ollama_chat"))
@pytest.mark.parametrize("use_async", (True, False), ids=("asystemone", "systemone"))
@pytest.mark.parametrize("api_base", ("http://ollama.local:11434", "http://ollama.local:11434/v1"))
@pytest.mark.parametrize("api_key", (None, "fake-api-key"))
async def test_ollama_systemone_preserves_questions_without_unrelated_or_placeholder_auth(
provider: str,
use_async: bool,
api_base: str,
api_key: str | None,
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
) -> None:
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "unrelated-openai-key")
monkeypatch.setattr(litellm, "api_key", "unrelated-global-key")
monkeypatch.setattr(litellm, "openai_key", "unrelated-global-openai-key")
route: Final = respx_mock.post("http://ollama.local:11434/v1/systemone").respond(json=_RESPONSE)
kwargs: Final = {
"model": f"{provider}/d3:4b",
"state": "review",
"questions": _QUESTIONS,
"api_base": api_base,
"api_key": api_key,
}
response: Final = await litellm.asystemone(**kwargs) if use_async else litellm.systemone(**kwargs)
assert json.loads(route.calls.last.request.content) == {
"model": "d3:4b",
"state": "review",
"questions": dict(_QUESTIONS),
}
assert "authorization" not in route.calls.last.request.headers
assert response.answers["is_defect"] == NoulAnswer(type="noul", noul=0.9)
assert response.answers["sentiment"].choice == "positive"
assert response.answers["severity"].score == 1
assert response.usage == DecisionsUsage(input_tokens=_INPUT_TOKENS, output_tokens=_OUTPUT_TOKENS)
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ("ollama", "ollama_chat"))
@pytest.mark.parametrize("use_async", (True, False), ids=("adecisions", "decisions"))
async def test_ollama_decisions_translates_openai_questions_and_answers(
provider: str, use_async: bool, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
route: Final = respx_mock.post("http://ollama.local:11434/v1/systemone").respond(json=_RESPONSE)
kwargs: Final = {
"model": f"{provider}/d3:4b",
"input": "review",
"questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
"api_base": "http://ollama.local:11434/v1",
}
response: Final = await litellm.adecisions(**kwargs) if use_async else litellm.decisions(**kwargs)
assert json.loads(route.calls.last.request.content) == {
"model": "d3:4b",
"state": "review",
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
}
assert "authorization" not in route.calls.last.request.headers
assert response.answers[0].type == "predicate"
assert response.answers[0].name == "is_defect"
assert response.answers[0].probability == 0.9
assert response.usage is not None
assert response.usage.input_tokens == _INPUT_TOKENS
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ("ollama", "ollama_chat"))
@pytest.mark.parametrize("environment_base", (None, "http://ollama.local:11434/v1"))
async def test_ollama_decisions_resolves_environment_base_or_localhost_default(
provider: str, environment_base: str | None, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
if environment_base is not None:
monkeypatch.setenv("OLLAMA_API_BASE", environment_base)
url: Final = f"{environment_base or 'http://localhost:11434/v1'}/systemone"
route: Final = respx_mock.post(url).respond(json=_RESPONSE)
response: Final = await litellm.asystemone(model=f"{provider}/d3:4b", state="review", questions=_QUESTIONS)
assert route.call_count == 1
assert "authorization" not in route.calls.last.request.headers
assert response.answers["is_defect"] == NoulAnswer(type="noul", noul=0.9)
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ("ollama", "ollama_chat"))
@pytest.mark.parametrize("explicit_key", (None, "deployment-key"))
async def test_ollama_decisions_resolves_environment_key_and_explicit_connection_overrides(
provider: str, explicit_key: str | None, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
monkeypatch.setenv("OLLAMA_API_BASE", "http://ignored.local:11434")
monkeypatch.setenv("OLLAMA_API_KEY", "environment-key")
route: Final = respx_mock.post("http://ollama.local:11434/v1/systemone").respond(json=_RESPONSE)
response: Final = await litellm.asystemone(
model=f"{provider}/d3:4b",
state="review",
questions=_QUESTIONS,
api_base="http://ollama.local:11434",
api_key=explicit_key,
)
assert route.calls.last.request.headers["authorization"] == f"Bearer {explicit_key or 'environment-key'}"
assert response.answers["is_defect"] == NoulAnswer(type="noul", noul=0.9)
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ("ollama", "ollama_chat"))
async def test_ollama_decisions_maps_upstream_bad_requests(
provider: str, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
route: Final = respx_mock.post("http://ollama.local:11434/v1/systemone").respond(
400, json={"error": "decision rejected"}
)
with pytest.raises(litellm.BadRequestError, match="decision rejected") as error:
await litellm.asystemone(
model=f"{provider}/d3:4b",
state="review",
questions=_QUESTIONS,
api_base="http://ollama.local:11434",
num_retries=0,
)
assert error.value.status_code == 400
assert route.call_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ("ollama", "ollama_chat"))
@pytest.mark.parametrize("drop_params", (False, True))
@pytest.mark.parametrize("image", ("data:image/png;base64,AA==", "https://images.example/input.png"))
async def test_ollama_decisions_rejects_input_images_before_http_or_drops_them(
provider: str, drop_params: bool, image: str, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
monkeypatch.setattr(litellm, "drop_params", False)
kwargs: Final = {
"model": f"{provider}/d3:4b",
"input": [
{
"role": "user",
"content": [{"type": "input_text", "text": "review"}, {"type": "input_image", "image_url": image}],
}
],
"questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
"api_base": "http://ollama.local:11434",
"drop_params": drop_params,
}
if not drop_params:
with pytest.raises(litellm.UnsupportedParamsError, match=r"\['input_image'\].*drop_params"):
await litellm.adecisions(**kwargs)
assert len(respx_mock.calls) == 0
return
route: Final = respx_mock.post("http://ollama.local:11434/v1/systemone").respond(json=_RESPONSE)
response: Final = await litellm.adecisions(**kwargs)
assert json.loads(route.calls.last.request.content) == {
"model": "d3:4b",
"state": "review",
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
}
assert response.answers[0].probability == 0.9
@pytest.mark.asyncio
@pytest.mark.parametrize("use_async", (True, False), ids=("asystemone", "systemone"))
@pytest.mark.parametrize("api_base", ("http://lmstudio.local:1234", "http://lmstudio.local:1234/v1"))

View file

@ -22,6 +22,7 @@ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig
from litellm.llms.gemini.common_utils import GeminiError
from litellm.types.agents import (
AgentCreateRequest,
AgentCreateResponse,
AgentDeleteResult,
AgentListResponse,
@ -104,7 +105,7 @@ class TestCreateAgent:
result = handler.create_agent(
agents_api_config=config,
name="agent-x",
create_request=AgentCreateRequest(id="agent-x"),
litellm_params=litellm_params,
logging_obj=logging_obj,
extra_headers={"X-Test": "1"},
@ -117,8 +118,7 @@ class TestCreateAgent:
client.post.assert_called_once()
kwargs = client.post.call_args.kwargs
assert kwargs["url"].endswith("/v1beta/agents")
assert kwargs["json"]["name"] == "agent-x"
assert kwargs["json"]["foo"] == "bar"
assert kwargs["json"] == {"id": "agent-x", "foo": "bar"}
assert kwargs["headers"]["X-Test"] == "1"
logging_obj.pre_call.assert_called_once()
logging_obj.post_call.assert_called_once()
@ -130,7 +130,7 @@ class TestCreateAgent:
result = handler.create_agent(
agents_api_config=config,
name="agent-x",
create_request=AgentCreateRequest(id="agent-x"),
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
@ -152,7 +152,7 @@ class TestCreateAgent:
with pytest.raises(GeminiError):
handler.create_agent(
agents_api_config=config,
name="agent-x",
create_request=AgentCreateRequest(id="agent-x"),
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,
@ -169,7 +169,7 @@ class TestCreateAgent:
result = await handler.async_create_agent(
agents_api_config=config,
name="agent-y",
create_request=AgentCreateRequest(id="agent-y"),
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
extra_body={"baz": "qux"},
@ -193,7 +193,7 @@ class TestCreateAgent:
with pytest.raises(GeminiError):
await handler.async_create_agent(
agents_api_config=config,
name="agent-y",
create_request=AgentCreateRequest(id="agent-y"),
litellm_params=litellm_params,
logging_obj=_make_logging_obj(),
client=client,

View file

@ -9,9 +9,14 @@ small helper utilities without touching the network.
"""
import asyncio
import json
from collections.abc import Callable, Mapping
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import respx
import litellm
@ -137,7 +142,7 @@ class TestSyncEntryPoints:
assert response is sentinel
handler.create_agent.assert_called_once()
kw = handler.create_agent.call_args.kwargs
assert kw["name"] == "waverunner"
assert kw["create_request"].id == "waverunner"
assert kw["_is_async"] is False
assert kw["extra_headers"] == {"X-Test": "1"}
assert kw["extra_body"] == {"foo": "bar"}
@ -205,7 +210,7 @@ class TestAsyncEntryPoints:
def fake_create_agent(**kwargs):
assert kwargs["_is_async"] is True
assert kwargs["name"] == "waverunner"
assert kwargs["create_request"].id == "waverunner"
return sentinel
handler = MagicMock()
@ -349,3 +354,250 @@ class TestAsyncErrorWrapping:
with patch(_HANDLER_PATH, handler):
with pytest.raises(litellm.APIConnectionError):
await alist_versions(name="waverunner", api_key="AIza")
_GEMINI_AGENTS_URL: Final = "https://generativelanguage.googleapis.com/v1beta/agents"
def _sent_json(route: respx.Route) -> object:
return json.loads(route.calls.last.request.content)
class TestCreateRequestBody:
def test_create_sends_every_current_field_verbatim(self, respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(_GEMINI_AGENTS_URL).mock(
return_value=httpx.Response(200, json={"id": "sentinel", "base_agent": "antigravity-preview-09-2026"})
)
response: Final = create(
id="sentinel",
base_agent="antigravity-preview-09-2026",
agent_config={"type": "antigravity", "model": "gemini-3.8-flash", "max_total_tokens": None},
system_instruction="Always answer in exactly three words.",
base_environment={"type": "remote", "sources": [{"type": "gcs", "source": "gs://bucket/skill"}]},
description="Watches the queue.",
tools=[{"type": "code_execution"}],
api_key="AIza-test",
)
assert _sent_json(route) == {
"id": "sentinel",
"base_agent": "antigravity-preview-09-2026",
"agent_config": {"type": "antigravity", "model": "gemini-3.8-flash", "max_total_tokens": None},
"system_instruction": "Always answer in exactly three words.",
"base_environment": {"type": "remote", "sources": [{"type": "gcs", "source": "gs://bucket/skill"}]},
"description": "Watches the queue.",
"tools": [{"type": "code_execution"}],
}
assert (response.id, response.name) == ("sentinel", "sentinel")
@pytest.mark.parametrize(
("identifier", "instruction", "expected"),
[
pytest.param(
{"name": "slides"},
{"instructions": "Make slides."},
{"id": "slides", "system_instruction": "Make slides."},
id="preview-aliases",
),
pytest.param(
{"id": "slides", "name": "slides"},
{"system_instruction": "Make slides.", "instructions": "Make slides."},
{"id": "slides", "system_instruction": "Make slides."},
id="same-value-under-both-names",
),
],
)
def test_create_sends_preview_aliases_under_current_names(
self,
respx_mock: respx.MockRouter,
identifier: Mapping[str, str],
instruction: Mapping[str, str],
expected: Mapping[str, str],
) -> None:
route: Final = respx_mock.post(_GEMINI_AGENTS_URL).mock(
return_value=httpx.Response(200, json={"id": "slides"})
)
create(**identifier, **instruction, base_agent="antigravity-preview-09-2026", api_key="AIza-test")
assert _sent_json(route) == {**expected, "base_agent": "antigravity-preview-09-2026"}
@pytest.mark.parametrize(
"fields",
[
pytest.param({"base_agent": "antigravity-preview-09-2026"}, id="no-identifier"),
pytest.param({"id": "slides", "name": "decks"}, id="different-id-and-name"),
pytest.param(
{"id": "slides", "system_instruction": "Make slides.", "instructions": "Make decks."},
id="different-system-instruction-and-instructions",
),
pytest.param({"id": "slides", "agent_config": "antigravity"}, id="agent-config-not-an-object"),
],
)
def test_create_rejects_a_malformed_identifier_or_instruction(
self, respx_mock: respx.MockRouter, fields: Mapping[str, str]
) -> None:
with pytest.raises(litellm.BadRequestError) as exc_info:
create(**fields, api_key="AIza-test")
assert exc_info.value.status_code == 400
assert not respx_mock.calls
def test_create_with_drop_params_drops_only_the_invalid_optional_fields(
self, respx_mock: respx.MockRouter
) -> None:
route: Final = respx_mock.post(_GEMINI_AGENTS_URL).mock(
return_value=httpx.Response(200, json={"id": "slides"})
)
create(
id="slides",
agent_config="antigravity",
tools="code_execution",
instructions="Make slides.",
drop_params=True,
api_key="AIza-test",
)
assert _sent_json(route) == {"id": "slides", "system_instruction": "Make slides."}
def test_create_with_drop_params_drops_a_clashing_instructions_alias(self, respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(_GEMINI_AGENTS_URL).mock(
return_value=httpx.Response(200, json={"id": "slides"})
)
create(
id="slides",
system_instruction="Make slides.",
instructions="Make decks.",
drop_params=True,
api_key="AIza-test",
)
assert _sent_json(route) == {"id": "slides", "system_instruction": "Make slides."}
def test_create_with_drop_params_still_rejects_a_clashing_identifier(self, respx_mock: respx.MockRouter) -> None:
with pytest.raises(litellm.BadRequestError) as exc_info:
create(id="slides", name="decks", drop_params=True, api_key="AIza-test")
assert exc_info.value.status_code == 400
assert not respx_mock.calls
@pytest.mark.parametrize(
("fields", "expected_message"),
[
pytest.param(
{"id": "slides", "instructions": 5, "tools": "code_execution"},
"Invalid agent create request: 'instructions' must be a string; 'tools' must be a list of objects."
" Set drop_params to drop invalid optional fields.",
id="names-the-alias-the-caller-sent",
),
pytest.param(
{"name": 7, "base_agent": "antigravity-preview-09-2026"},
"Invalid agent create request: 'name' must be a string.",
id="names-the-id-alias-the-caller-sent",
),
pytest.param(
{"id": 7, "agent_config": "antigravity", "drop_params": True},
"Invalid agent create request: 'agent_config' must be an object; 'id' must be a string.",
id="invalid-id-is-never-dropped",
),
],
)
def test_create_rejects_invalid_fields_by_the_names_the_caller_sent(
self, respx_mock: respx.MockRouter, fields: Mapping[str, object], expected_message: str
) -> None:
with pytest.raises(litellm.BadRequestError) as exc_info:
create(**fields, api_key="AIza-test")
assert exc_info.value.status_code == 400
assert exc_info.value.message.endswith(expected_message)
assert not respx_mock.calls
@pytest.mark.asyncio
async def test_acreate_sends_every_current_field_verbatim(
self, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
route: Final = respx_mock.post(_GEMINI_AGENTS_URL).mock(
return_value=httpx.Response(200, json={"id": "sentinel"})
)
response: Final = await acreate(
id="sentinel",
agent_config={"type": "antigravity", "model": "gemini-3.8-flash"},
system_instruction="Always answer in exactly three words.",
api_key="AIza-test",
)
assert _sent_json(route) == {
"id": "sentinel",
"agent_config": {"type": "antigravity", "model": "gemini-3.8-flash"},
"system_instruction": "Always answer in exactly three words.",
}
assert response.id == "sentinel"
class TestAgentIdentifier:
def test_get_by_id_fetches_that_agent(self, respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.get(f"{_GEMINI_AGENTS_URL}/slides").mock(
return_value=httpx.Response(200, json={"id": "slides", "base_agent": "antigravity-preview-09-2026"})
)
response: Final = get(id="slides", api_key="AIza-test")
assert route.call_count == 1
assert (response.id, response.base_agent) == ("slides", "antigravity-preview-09-2026")
def test_delete_by_id_deletes_that_agent(self, respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.delete(f"{_GEMINI_AGENTS_URL}/slides").mock(
return_value=httpx.Response(200, json={})
)
result: Final = delete(id="slides", api_key="AIza-test")
assert route.call_count == 1
assert (result.name, result.deleted) == ("slides", True)
def test_list_versions_by_id_lists_that_agents_versions(self, respx_mock: respx.MockRouter) -> None:
version: Final = {"agent": "slides", "name": "agents/slides/versions/v1"}
route: Final = respx_mock.get(f"{_GEMINI_AGENTS_URL}/slides/versions").mock(
return_value=httpx.Response(200, json={"agentVersions": [version]})
)
response: Final = list_versions(id="slides", api_key="AIza-test")
assert route.call_count == 1
assert response.agent_versions == [version]
@pytest.mark.parametrize(
"operation",
[pytest.param(get, id="get"), pytest.param(delete, id="delete"), pytest.param(list_versions, id="list_versions")],
)
@pytest.mark.parametrize(
"identifier",
[
pytest.param({}, id="no-identifier"),
pytest.param({"id": "slides", "name": "decks"}, id="different-id-and-name"),
],
)
def test_rejects_a_missing_or_conflicting_identifier(
self,
respx_mock: respx.MockRouter,
operation: Callable[..., object],
identifier: Mapping[str, str],
) -> None:
with pytest.raises(litellm.BadRequestError) as exc_info:
operation(**identifier, api_key="AIza-test")
assert exc_info.value.status_code == 400
assert not respx_mock.calls
@pytest.mark.asyncio
async def test_aget_rejects_a_missing_identifier(self, respx_mock: respx.MockRouter) -> None:
with pytest.raises(litellm.BadRequestError) as exc_info:
await aget(api_key="AIza-test")
assert exc_info.value.status_code == 400
assert not respx_mock.calls

View file

@ -236,6 +236,159 @@ class TestGoogleInteractionsCreate:
assert json.loads(route.calls.last.request.content) == expected_body
assert response.status == "completed"
@pytest.mark.usefixtures("fake_provider_credentials")
def test_create_with_agent_config_sends_it_to_gemini(self, respx_mock: MockRouter) -> None:
route: Final = respx_mock.post("https://generativelanguage.googleapis.com/v1beta/interactions").mock(
return_value=httpx.Response(
200,
json={"id": "interaction-agent", "object": "interaction", "status": "completed"},
)
)
interactions.create(
agent="antigravity-preview-09-2026",
agent_config={"type": "antigravity", "model": "gemini-3.8-flash"},
input="Say hi in one word.",
environment="remote",
api_key="gemini-offline",
)
assert json.loads(route.calls.last.request.content) == {
"agent": "antigravity-preview-09-2026",
"agent_config": {"type": "antigravity", "model": "gemini-3.8-flash"},
"input": "Say hi in one word.",
"environment": "remote",
}
@pytest.mark.usefixtures("fake_provider_credentials")
def test_create_keeps_positional_response_settings_in_place(self, respx_mock: MockRouter) -> None:
route: Final = respx_mock.post("https://generativelanguage.googleapis.com/v1beta/interactions").mock(
return_value=httpx.Response(200, json={"id": "interaction-model", "status": "completed"})
)
interactions.create(
"gemini/gemini-2.5-flash",
None,
"Describe a cat.",
None,
None,
None,
None,
None,
None,
None,
["TEXT"],
api_key="gemini-offline",
)
assert json.loads(route.calls.last.request.content) == {
"model": "gemini-2.5-flash",
"input": "Describe a cat.",
"response_modalities": ["TEXT"],
}
@pytest.mark.parametrize(
("target", "agent_config", "expected_target"),
[
pytest.param(
{"model": "gemini/gemini-2.5-flash"},
{"type": "antigravity", "model": "gemini-3.8-flash"},
{"model": "gemini-2.5-flash"},
id="model-drops-it",
),
pytest.param(
{"agent": "antigravity-preview-09-2026"},
{"type": "antigravity", "model": "gemini-3.8-flash"},
{
"agent": "antigravity-preview-09-2026",
"agent_config": {"type": "antigravity", "model": "gemini-3.8-flash"},
},
id="agent-keeps-an-object",
),
pytest.param(
{"agent": "antigravity-preview-09-2026"},
"antigravity",
{"agent": "antigravity-preview-09-2026"},
id="agent-drops-a-non-object",
),
pytest.param(
{"agent": "antigravity-preview-09-2026"},
{"model": "gemini-3.8-flash"},
{"agent": "antigravity-preview-09-2026"},
id="agent-drops-an-object-without-a-type",
),
pytest.param(
{"agent": "antigravity-preview-09-2026"},
{"type": 7, "model": "gemini-3.8-flash"},
{"agent": "antigravity-preview-09-2026"},
id="agent-drops-a-non-string-type",
),
],
)
@pytest.mark.usefixtures("fake_provider_credentials")
def test_create_with_drop_params_drops_agent_config_gemini_cannot_take(
self,
respx_mock: MockRouter,
target: Mapping[str, str],
agent_config: object,
expected_target: Mapping[str, object],
) -> None:
route: Final = respx_mock.post("https://generativelanguage.googleapis.com/v1beta/interactions").mock(
return_value=httpx.Response(200, json={"id": "interaction-drop", "status": "completed"})
)
interactions.create(
**target,
agent_config=agent_config,
input="Say hi in one word.",
drop_params=True,
api_key="gemini-offline",
)
assert json.loads(route.calls.last.request.content) == {**expected_target, "input": "Say hi in one word."}
@pytest.mark.usefixtures("fake_provider_credentials")
def test_create_rejects_agent_config_a_bridged_provider_cannot_take(self, respx_mock: MockRouter) -> None:
route: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
return_value=httpx.Response(200, json=_openai_response_body("resp-agent-config", "Hi"))
)
with pytest.raises(litellm.UnsupportedParamsError, match="'agent_config' is not supported for openai") as e:
interactions.create(
model="gpt-5.6",
agent_config={"type": "antigravity", "model": "gemini-3.8-flash"},
input="Say hi in one word.",
api_key="sk-offline",
)
assert e.value.status_code == 400
assert not route.called
@pytest.mark.parametrize(
"drop_kwargs",
[
pytest.param({"drop_params": True}, id="drop-params"),
pytest.param({"additional_drop_params": ["agent_config"]}, id="additional-drop-params"),
],
)
@pytest.mark.usefixtures("fake_provider_credentials")
def test_create_with_drop_params_drops_agent_config_a_bridged_provider_cannot_take(
self, respx_mock: MockRouter, drop_kwargs: Mapping[str, object]
) -> None:
route: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
return_value=httpx.Response(200, json=_openai_response_body("resp-agent-config", "Hi"))
)
interactions.create(
model="gpt-5.6",
agent_config={"type": "antigravity", "model": "gemini-3.8-flash"},
input="Say hi in one word.",
api_key="sk-offline",
**drop_kwargs,
)
assert json.loads(route.calls.last.request.content) == {"model": "gpt-5.6", "input": "Say hi in one word."}
@pytest.mark.parametrize(
("model", "api_key", "url", "expected_body", "sse_body", "expected_chunks"),
[

View file

@ -37,6 +37,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
schema_has_unresolvable_ref,
schema_property_count,
split_concatenated_json_objects,
split_media_for_tpm,
strip_encrypted_reasoning_from_messages,
strip_litellm_response_only_fields,
system_messages_first,
@ -2512,3 +2513,118 @@ def test_shared_image_url_agrees_with_responses_normalization(
expected: Final = {"type": "image_url", "image_url": {"url": "data:image/png;base64,IMAGE"}}
assert as_openai_image_part(part) == expected
assert as_openai_image_part(normalized) == expected
@pytest.mark.parametrize(
"part,projected,media",
[
(
{"type": "image_url", "image_url": {"url": "data:image/png;base64,IMAGE", "detail": "high"}},
{"type": "image_url", "image_url": {"url": "", "detail": "high"}},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,IMAGE", "detail": "high"}},
),
(
{"type": "input_image", "image_url": "https://example.com/image.png", "detail": "low"},
{"type": "input_image", "image_url": "", "detail": "low"},
{"type": "image_url", "image_url": {"url": "https://example.com/image.png", "detail": "low"}},
),
(
{"type": "computer_screenshot", "image_url": "data:image/png;base64,IMAGE"},
{"type": "computer_screenshot", "image_url": ""},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,IMAGE", "detail": "auto"}},
),
(
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "IMAGE"}},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,IMAGE", "detail": "auto"}},
),
(
{"type": "input_audio", "input_audio": {"data": "AUDIO", "format": "wav"}},
{"type": "input_audio", "input_audio": {"data": "", "format": "wav"}},
{"type": "input_audio", "input_audio": {"data": "AUDIO", "format": "wav"}},
),
(
{"type": "file", "file": {"file_data": "IMAGE", "format": "image/png", "filename": "image.png"}},
{"type": "file", "file": {"file_data": "", "format": "image/png", "filename": "image.png"}},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,IMAGE", "detail": "auto"}},
),
(
{"type": "input_file", "file_data": "data:audio/wav;base64,AUDIO", "filename": "audio.wav"},
{"type": "input_file", "file_data": "", "filename": "audio.wav"},
{"type": "input_audio", "input_audio": {"data": "AUDIO", "format": "wav"}},
),
],
)
def test_tpm_projection_blanks_only_declared_media_transport(
part: Mapping[str, object], projected: Mapping[str, object], media: Mapping[str, object],
) -> None:
text: Final = {"type": "text", "text": "data:image/png;base64,IMAGE"}
request: Final = [{"role": "user", "content": [text, {**part, "cache_control": {"type": "ephemeral"}}]}]
original: Final = copy.deepcopy(request)
assert split_media_for_tpm(request) == (
[{"role": "user", "content": [text, {**projected, "cache_control": {"type": "ephemeral"}}]}],
(media,),
)
assert request == original
def test_tpm_projection_retains_nested_and_native_history_around_media() -> None:
image: Final = {"type": "image_url", "image_url": {"url": "data:image/png;base64,IMAGE", "detail": "high"}}
empty_image: Final = {"type": "image_url", "image_url": {"url": "", "detail": "high"}}
document: Final = {"type": "document", "title": "keep", "source": {"type": "content", "content": [image]}}
empty_document: Final = {**document, "source": {"type": "content", "content": [empty_image]}}
tool: Final = {"type": "tool_result", "tool_use_id": "call", "content": [document, {"type": "text", "text": "keep"}]}
empty_tool: Final = {**tool, "content": [empty_document, {"type": "text", "text": "keep"}]}
request: Final = [
{"role": "user", "content": [tool]},
{"type": "function_call_output", "call_id": "call", "output": [image]},
{"type": "custom_tool_call_output", "call_id": "custom", "output": [image]},
{"type": "computer_call_output", "call_id": "computer", "output": {
"type": "computer_screenshot", "image_url": "data:image/png;base64,IMAGE",
}},
{"type": "image_generation_call", "id": "ig", "result": "IMAGE", "status": "completed"},
{"type": "code_interpreter_call", "id": "ci", "code": "keep()", "outputs": [
{"type": "logs", "logs": "keep"}, {"type": "image", "url": "https://example.com/image.png"},
]},
]
original: Final = copy.deepcopy(request)
projected, media = split_media_for_tpm(request)
assert projected == [
{"role": "user", "content": [empty_tool]},
{"type": "function_call_output", "call_id": "call", "output": [empty_image]},
{"type": "custom_tool_call_output", "call_id": "custom", "output": [empty_image]},
{"type": "computer_call_output", "call_id": "computer", "output": {
"type": "computer_screenshot", "image_url": "",
}},
{"type": "image_generation_call", "id": "ig", "result": "", "status": "completed"},
{"type": "code_interpreter_call", "id": "ci", "code": "keep()", "outputs": [
{"type": "logs", "logs": "keep"}, {"type": "image", "url": ""},
]},
]
assert media == (
image, image, image,
{"type": "image_url", "image_url": {"url": "data:image/png;base64,IMAGE", "detail": "auto"}},
{"type": "image_url", "image_url": {"url": "data:application/octet-stream;base64,IMAGE", "detail": "auto"}},
{"type": "image_url", "image_url": {"url": "https://example.com/image.png", "detail": "auto"}},
)
assert request == original
def test_tpm_projection_preserves_non_media_identity_and_opaque_values() -> None:
image: Final = {"type": "input_image", "image_url": "data:image/png;base64,OPAQUE"}
request: Final = [
{"role": "user", "content": json.dumps(image)},
{"type": "custom_tool_call_output", "call_id": "call", "output": json.dumps(image)},
{"type": "function_call", "call_id": "call", "name": "keep", "arguments": {"content": [image]}},
{"type": "file_search_call", "results": [{"text": "keep", "content": [image]}]},
{"type": "image_generation_call", "id": "ig", "status": "in_progress", "result": None},
{"role": "user", "content": [
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "PDF"}},
{"type": "document", "source": {"type": "text", "data": json.dumps(image)}},
{"type": "file", "file": {"format": "application/pdf", "file_data": "PDF"}},
{"type": "text", "text": "keep", "metadata": {"content": [image]}},
]},
]
projected, media = split_media_for_tpm(request)
assert projected is request
assert media == ()

View file

@ -2,16 +2,23 @@
Test the audio utils functionality in litellm_core_utils/audio_utils/utils.py
"""
import base64
import io
import os
import tempfile
from typing import Final
from unittest.mock import mock_open, patch
import pytest
import soundfile as sf
from litellm.litellm_core_utils.audio_utils.utils import (
AUDIO_BYTES_PER_TOKEN,
AUDIO_TOKENS_PER_SECOND,
DEFAULT_AUDIO_TOKEN_ESTIMATE,
ProcessedAudioFile,
calculate_request_duration,
estimate_audio_tokens,
get_audio_file_content_hash,
get_audio_file_for_health_check,
get_audio_file_name,
@ -19,6 +26,63 @@ from litellm.litellm_core_utils.audio_utils.utils import (
)
def _encoded_audio(seconds: int, sample_rate: int, channels: int, container: str) -> str:
buffer: Final = io.BytesIO()
with sf.SoundFile(buffer, "w", samplerate=sample_rate, channels=channels, format=container) as audio:
audio.buffer_write(b"\0" * (seconds * sample_rate * channels * 2), dtype="int16")
return base64.b64encode(buffer.getvalue()).decode()
@pytest.mark.parametrize(
("sample_rate", "channels", "container"), [(8000, 1, "WAV"), (48000, 2, "WAV"), (24000, 1, "FLAC")]
)
def test_audio_estimate_uses_duration_independent_of_sample_rate_channels_and_compression(
sample_rate: int, channels: int, container: str
) -> None:
assert estimate_audio_tokens(_encoded_audio(1, sample_rate, channels, container)) == AUDIO_TOKENS_PER_SECOND
assert estimate_audio_tokens(_encoded_audio(31, sample_rate, channels, container)) == 31 * AUDIO_TOKENS_PER_SECOND
# Audio rates verified 2026-10-28: https://ai.google.dev/gemini-api/docs/generate-content/media-resolution and
# https://developers.openai.com/api/docs/guides/voice-latency-cost
@pytest.mark.parametrize(
("models", "fallback", "expected"),
[
(("openai/gpt-audio-1.5",), "gemini-3.8-flash", 10),
(("gpt-audio-1.5", "gemini/gemini-3.8-flash"), None, 25),
(("vertex_ai/gemini-3.8-flash",), None, 25),
(("openrouter/google/gemini-3.8-flash",), None, 25),
(("azure/audio-deployment",), "gpt-audio-1.5", 10),
(("opaque-deployment",), "gemini-3.8-flash", 25),
((), "gemini-3.8-flash", 25),
(("gemini/gemini-2.5-flash", "gemini/gemini-3-pro-preview"), None, 32),
(("other/gemini-3.8-flash",), None, 10),
],
)
def test_audio_estimate_prefers_each_backend_rate_over_the_requested_model(
models: tuple[str, ...], fallback: str | None, expected: int
) -> None:
assert estimate_audio_tokens(_encoded_audio(1, 8000, 1, "WAV"), models, fallback) == expected
@pytest.mark.parametrize("data", [None, "", "invalid-base64!", base64.b64encode(b"unreadable audio").decode()])
def test_audio_estimate_retains_nonzero_fallback_for_unreadable_audio(data: str | None) -> None:
assert estimate_audio_tokens(data) == DEFAULT_AUDIO_TOKEN_ESTIMATE
def test_audio_estimate_retains_size_based_fallback_for_large_unreadable_audio() -> None:
data: Final = base64.b64encode(b"\0" * (AUDIO_BYTES_PER_TOKEN * 400)).decode()
assert estimate_audio_tokens(data) == 400
def test_audio_estimate_bounds_base64_allocation_before_decoding() -> None:
data: Final = base64.b64encode(b"\0" * (16 * 1024 * 1024 + 1)).decode()
with patch("base64.b64decode", side_effect=AssertionError("oversized audio must not be decoded")) as decode:
estimate: Final = estimate_audio_tokens(data)
assert estimate == len(data) * 3 // 4 // AUDIO_BYTES_PER_TOKEN
decode.assert_not_called()
class TestProcessAudioFile:
"""Test the process_audio_file function with various input types"""

View file

@ -723,6 +723,37 @@ async def test_ahealth_check_probes_hosted_vllm_with_a_choice_question(
}
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ("ollama", "ollama_chat"))
async def test_ahealth_check_probes_ollama_decisions_with_one_noul_question_without_auth(
provider: str, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.in_memory_llm_clients_cache.flush_cache()
upstream: Final = respx_mock.post("http://ollama.local:11434/v1/systemone").respond(
json={
"model": "d3:4b",
"answers": {"reachable": {"type": "noul", "noul": 1.0}},
"usage": {"input_tokens": 12, "output_tokens": 1},
}
)
result: Final = await ahealth_check(
{"model": f"{provider}/d3:4b", "api_base": "http://ollama.local:11434"}, mode="decisions"
)
assert "error" not in result, result
assert upstream.call_count == 1
assert "authorization" not in upstream.calls.last.request.headers
assert json.loads(upstream.calls.last.request.content) == {
"model": "d3:4b",
"state": "health check",
"questions": {"reachable": {"type": "noul", "instructions": "Is the service reachable?"}},
}
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ("evaluation", "decisions"))
async def test_ahealth_check_keeps_the_noul_probe_for_other_decisions_providers(

View file

@ -1,5 +1,6 @@
import asyncio
import datetime
import itertools
import json
import queue
import sys
@ -13,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
from openai import AsyncOpenAI, AsyncStream, OpenAI, Stream
from openai.types.chat import ChatCompletionChunk
from pydantic import BaseModel, TypeAdapter
import litellm
@ -28,6 +30,7 @@ from litellm.litellm_core_utils.streaming_handler import (
_ProviderChunkParsed,
)
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.openai.chat.gpt_transformation import OpenAIChatCompletionStreamingHandler
from litellm.responses.mcp.chat_completions_handler import _add_mcp_metadata_to_response
from litellm.types.utils import (
CompletionTokensDetailsWrapper,
@ -1367,6 +1370,148 @@ async def test_bedrock_validation_error_raises_directly(logging_obj: Logging):
assert getattr(excinfo.value, "status_code", None) == 400
@pytest.mark.parametrize("async_stream", [False, True])
@pytest.mark.parametrize("transport", ["sdk", "http_handler"])
@pytest.mark.parametrize("prefix", ["", "Partial answer"])
@pytest.mark.parametrize("abort_index", [0, 1])
@pytest.mark.parametrize("abort_content", ["", "unfinished"])
async def test_aborted_stream_raises_instead_of_finishing_successfully(
async_stream: bool, transport: str, prefix: str, abort_index: int, abort_content: str, logging_obj: Logging
) -> None:
initial: Final = (
(({"index": 0, "delta": {"role": "assistant", "content": prefix}, "finish_reason": None},),) if prefix else ()
)
final: Final = tuple(
{
"index": index,
"delta": {"content": abort_content},
"finish_reason": "abort" if index == abort_index else "stop",
}
for index in range(abort_index + 1)
)
body: Final = (
"".join(
"data: "
+ json.dumps(
{
"id": "chatcmpl-aborted",
"object": "chat.completion.chunk",
"created": 1,
"model": "test-model",
"choices": choices,
}
)
+ "\n\n"
for choices in (*initial, final)
)
+ "data: [DONE]\n\n"
)
raw_response: Final = httpx.Response(
200,
content=body,
headers={"content-type": "text/event-stream"},
request=httpx.Request("POST", "https://provider.example/v1/chat/completions"),
)
client: Final = AsyncOpenAI(api_key="unused") if async_stream else OpenAI(api_key="unused")
stream: Final = (
(
AsyncStream(cast_to=ChatCompletionChunk, response=raw_response, client=client)
if isinstance(client, AsyncOpenAI)
else Stream(cast_to=ChatCompletionChunk, response=raw_response, client=client)
)
if transport == "sdk"
else OpenAIChatCompletionStreamingHandler(
streaming_response=raw_response.aiter_lines() if async_stream else raw_response.iter_lines(),
sync_stream=not async_stream,
)
)
response: Final = CustomStreamWrapper(
completion_stream=stream,
model="test-model",
logging_obj=logging_obj,
custom_llm_provider="openai" if transport == "sdk" else "hosted_vllm",
)
try:
with pytest.raises(MidStreamFallbackError, match="abort") as excinfo:
_chunks: Final = [chunk async for chunk in response] if async_stream else list(response)
assert excinfo.value.is_pre_first_chunk is (not prefix)
assert excinfo.value.generated_content == prefix
assert getattr(excinfo.value.original_exception, "status_code", None) == 500
finally:
if isinstance(client, AsyncOpenAI):
await client.close()
else:
client.close()
@pytest.mark.parametrize("async_stream", [False, True])
@pytest.mark.parametrize("transport", ["sdk", "http_handler"])
async def test_stream_with_null_choices_chunk_finishes_normally(
async_stream: bool, transport: str, logging_obj: Logging
) -> None:
body: Final = (
"".join(
"data: "
+ json.dumps(
{
"id": "chatcmpl-null-choices",
"object": "chat.completion.chunk",
"created": 1,
"model": "test-model",
"choices": choices,
**extra,
}
)
+ "\n\n"
for choices, extra in (
([{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}], {}),
([{"index": 0, "delta": {}, "finish_reason": "stop"}], {}),
(None, {"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}),
)
)
+ "data: [DONE]\n\n"
)
raw_response: Final = httpx.Response(
200,
content=body,
headers={"content-type": "text/event-stream"},
request=httpx.Request("POST", "https://provider.example/v1/chat/completions"),
)
client: Final = AsyncOpenAI(api_key="unused") if async_stream else OpenAI(api_key="unused")
stream: Final = (
(
AsyncStream(cast_to=ChatCompletionChunk, response=raw_response, client=client)
if isinstance(client, AsyncOpenAI)
else Stream(cast_to=ChatCompletionChunk, response=raw_response, client=client)
)
if transport == "sdk"
else OpenAIChatCompletionStreamingHandler(
streaming_response=raw_response.aiter_lines() if async_stream else raw_response.iter_lines(),
sync_stream=not async_stream,
)
)
response: Final = CustomStreamWrapper(
completion_stream=stream,
model="test-model",
logging_obj=logging_obj,
custom_llm_provider="openai" if transport == "sdk" else "hosted_vllm",
)
try:
chunks: Final = [chunk async for chunk in response] if async_stream else list(response)
finally:
if isinstance(client, AsyncOpenAI):
await client.close()
else:
client.close()
streamed_choices: Final = tuple(itertools.chain.from_iterable(chunk.choices for chunk in chunks))
assert "".join(choice.delta.content or "" for choice in streamed_choices) == "hi"
assert [choice.finish_reason for choice in streamed_choices if choice.finish_reason] == ["stop"]
def _hosted_vllm_stream_wrapper(logging_obj: Logging, error_payload: dict) -> CustomStreamWrapper:
"""A CustomStreamWrapper over the real OpenAI-compatible line iterator,
fed an HTTP 200 SSE body that carries an in-body error payload the way

View file

@ -37,6 +37,7 @@ from litellm.litellm_core_utils.token_counter import (
_get_extrapolating_count_function,
_get_tiktoken_count_function,
calculate_img_tokens,
estimate_image_tokens,
high_detail_image_token_upper_bound,
offload_token_count,
)
@ -2152,6 +2153,136 @@ def test_high_detail_image_token_upper_bound_is_reached_by_the_largest_high_res_
assert calculate_img_tokens(_png_data_url(1, 1), mode="high") < high_detail_image_token_upper_bound()
# Image sizing verified 2026-10-28: https://developers.openai.com/api/docs/guides/images-vision
# and https://platform.claude.com/docs/en/build-with-claude/vision-coordinates
@pytest.mark.parametrize(
("model", "width", "height", "detail", "expected"),
[
("gpt-4o-mini", 1024, 1024, "low", 2833),
("gpt-4o-mini", 1024, 1024, "high", 25501),
("gpt-5.1", 512, 512, "auto", 210),
("gpt-6-astra", 2048, 2048, "high", 3000),
("gpt-6-astra", 4096, 512, "high", 2458),
("gpt-6-astra", 2048, 2048, "auto", 4916),
("gpt-5.6-sol", 2048, 2048, "low", 308),
("gpt-5.5", 6400, 6400, "original", 12000),
("gpt-5.4-mini", 2048, 2048, "low", 4916),
("gpt-5.4-nano", 2048, 2048, "auto", 3000),
("gpt-5.2", 4096, 512, "auto", 615),
("gpt-4.1-mini-2025-04-14", 1024, 1024, "low", 1659),
("claude-haiku-5-5", 200, 200, "auto", 64),
("anthropic/claude-haiku-5-5", 1024, 1024, "auto", 1369),
("claude-haiku-5-5", 1920, 1080, "low", 2691),
("claude-haiku-4-5", 1920, 1080, "auto", 1560),
("claude-haiku-4-5", 1075, 1520, "auto", 1551),
("claude-haiku-5-5", 3840, 2160, "auto", 4784),
("claude-haiku-5-5", 8000, 100, "auto", 184),
],
)
def test_image_estimate_uses_model_resolution_and_detail(
model: str, width: int, height: int, detail: str, expected: int
) -> None:
assert estimate_image_tokens(model, _png_data_url(width, height), detail) == expected
@pytest.mark.parametrize(
("models", "fallback", "expected_model"),
[
(("azure/deployment-prod",), "gpt-4o", "gpt-4o"),
(("openai/gpt-4o",), "gpt-4o-mini", "gpt-4o"),
(("azure/deployment-prod", "openai/gpt-4o"), "gpt-4o-mini", "gpt-4o-mini"),
(("openai/gpt-4o", "openrouter/openai/gpt-4o-mini"), None, "gpt-4o-mini"),
(("azure_ai/openai/gpt-6-astra",), None, "gpt-6-astra"),
(("azure/gpt-41",), None, "gpt-4.1"),
(("azure/eu/gpt-4o-mini",), None, "gpt-4o-mini"),
(("unknown", "gpt-4o-mini"), None, "gpt-4o-mini"),
((), "gpt-6-astra", "gpt-6-astra"),
(("other/gpt-4o-mini",), None, "unregistered-model"),
(("azure/opaque/gpt-4o-mini",), None, "unregistered-model"),
(("bedrock/anthropic.claude-haiku-5-5",), None, "claude-haiku-5-5"),
(("bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",), None, "claude-haiku-4-5"),
(("vertex_ai/claude-haiku-5-5@default",), None, "claude-haiku-5-5"),
(("azure_ai/claude-haiku-5-5",), None, "claude-haiku-5-5"),
(("anthropic/claude-haiku-5-5", "openai/gpt-4o"), "gpt-4o-mini", "claude-haiku-5-5"),
(("other/claude-haiku-5-5",), None, "unregistered-model"),
],
)
def test_image_estimate_prefers_each_backend_policy_over_the_requested_model(
models: tuple[str, ...], fallback: str | None, expected_model: str
) -> None:
data: Final = _png_data_url(1024, 1024)
assert estimate_image_tokens(models, data, "high", fallback_model=fallback) == estimate_image_tokens(
expected_model, data, "high"
)
@pytest.mark.parametrize(
("model", "detail"),
[
("unknown", "high"),
("claude-unregistered", "auto"),
("gpt-4o", "original"),
("gpt-5.2", "original"),
("gpt-6-astra", "invalid"),
],
)
def test_image_estimate_preserves_default_when_policy_is_unavailable(model: str, detail: str) -> None:
assert (
estimate_image_tokens(model, _png_data_url(1024, 1024), detail) == litellm.constants.DEFAULT_IMAGE_TOKEN_COUNT
)
@pytest.mark.parametrize(
"data",
[
"https://image.invalid/photo.png",
"data:image/png;base64,invalid!",
"data:application/octet-stream;base64,bm90LWFuLWltYWdl",
"data:image/jpeg;base64,/9j/4AAQ",
"data:image/webp;base64,UklGRgAAAABXRUJQ",
_png_data_url(0, 1024),
],
)
# Accepted patch limits verified 2026-10-28: https://developers.openai.com/api/docs/guides/images-vision
# and https://platform.claude.com/docs/en/build-with-claude/vision
@pytest.mark.parametrize(
("model", "detail", "bound"),
[
("gpt-4o", "high", high_detail_image_token_upper_bound()),
("gpt-6-astra", "high", 3000),
("gpt-6-astra", "auto", 36000),
("claude-haiku-5-5", "auto", 4784),
("claude-haiku-4-5", "auto", 1568),
],
)
def test_image_estimate_uses_bounded_fallback_without_fetching_media(
data: str, model: str, detail: str, bound: int
) -> None:
with patch("httpx.Client.send") as send:
estimate: Final = estimate_image_tokens(model, data, detail)
send.assert_not_called()
assert estimate == bound
assert estimate > estimate_image_tokens(model, _png_data_url(1024, 1024), detail)
@pytest.mark.parametrize("container", ["PNG", "JPEG", "GIF", "WEBP"])
def test_image_estimate_depends_on_dimensions_and_not_compressed_payload_size(container: str) -> None:
output: Final = io.BytesIO()
Image.new("RGB", (1024, 1024)).save(output, format=container)
data: Final = "data:application/octet-stream;base64," + base64.b64encode(output.getvalue()).decode()
assert estimate_image_tokens("gpt-6-astra", data) == estimate_image_tokens("gpt-6-astra", _png_data_url(1024, 1024))
def test_image_estimate_bounds_header_decoding_for_large_inline_payloads() -> None:
header: Final = base64.b64decode(_png_data_url(1024, 1024).split(",", 1)[1])
data: Final = "data:image/png;base64," + base64.b64encode(header + b"\0" * 4_000_000).decode()
with patch("base64.b64decode", wraps=base64.b64decode) as decode_header:
estimate: Final = estimate_image_tokens("gpt-6-astra", data)
assert estimate == estimate_image_tokens("gpt-6-astra", _png_data_url(1024, 1024))
assert decode_header.call_count == 1
assert len(decode_header.call_args.args[0]) <= 65_536
HUB_SETUP_SCRIPT: Final = """
import json
import sys

View file

@ -241,20 +241,14 @@ print("compatible")
@pytest.mark.parametrize("rust", (None, "0", "1"))
def test_tokenization_without_native_extension_stays_offline(tmp_path: Path, rust: str | None) -> None:
def test_tokenization_stays_offline(tmp_path: Path, rust: str | None) -> None:
script: Final = """
import importlib.abc
import sys
sys.path.insert(0, sys.argv[1])
def reject_network(event, args):
if event == "socket.connect":
raise AssertionError("tokenizer attempted a network connection")
sys.addaudithook(reject_network)
class Block(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname == "litellm.rust_bridge._native":
raise ImportError("native extension is unavailable")
sys.meta_path.insert(0, Block())
import litellm
from litellm.rust_bridge.tokenizer import get_encoding
import tiktoken

View file

@ -1,15 +1,16 @@
from typing import Final
from unittest.mock import AsyncMock, Mock
import pytest
from aiohttp import ClientResponse
from pydantic import ValidationError
from pydantic import TypeAdapter, ValidationError
from litellm.llms.aiohttp_openai.chat.transformation import AiohttpOpenAIChatConfig
from litellm.types.utils import ModelResponse
async def _transform(body: object) -> ModelResponse:
raw_response = Mock(spec=ClientResponse)
async def _transform(body: object, *, requested_tier: str | None = None) -> ModelResponse:
raw_response: Final = Mock(spec=ClientResponse)
raw_response.json = AsyncMock(return_value=body)
return await AiohttpOpenAIChatConfig().transform_response(
model="gpt-4o",
@ -18,7 +19,7 @@ async def _transform(body: object) -> ModelResponse:
logging_obj=Mock(),
request_data={},
messages=[{"role": "user", "content": "Hello"}],
optional_params={},
optional_params={"service_tier": requested_tier} if requested_tier is not None else {},
litellm_params={},
encoding=None,
)
@ -99,3 +100,20 @@ async def test_transform_response_rejects_choices_that_are_not_a_list_of_objects
async def test_transform_response_rejects_a_choice_whose_message_is_not_an_object():
with pytest.raises(ValidationError, match="validation error for Choices"):
await _transform({"choices": [{"message": "text"}]})
@pytest.mark.parametrize("tier", ("flex", "priority", "default", "custom-tier", None))
async def test_transform_response_serializes_served_tier_instead_of_requested_tier(tier: str | None) -> None:
response: Final = await _transform(
{"id": "chatcmpl-1", "choices": [{}], "service_tier": tier}, requested_tier="auto"
)
serialized: Final = TypeAdapter(dict[str, object]).validate_json(response.model_dump_json())
assert serialized["service_tier"] == tier
async def test_transform_response_does_not_invent_a_served_tier_when_omitted() -> None:
response: Final = await _transform({"id": "chatcmpl-1", "choices": [{}]}, requested_tier="flex")
serialized: Final = TypeAdapter(dict[str, object]).validate_json(response.model_dump_json())
assert "service_tier" not in serialized

View file

@ -33,6 +33,13 @@ PRICE_FIELDS: Final = (
"cache_read_input_token_cost",
)
PUBLISHED_DBU_PER_MILLION: Final = {
"databricks/databricks-claude-haiku-5-5": ("1.429", "7.143", "1.786", "0.143"),
"databricks/databricks-claude-sonnet-5-5": ("28.571", "142.857", "35.714", "1.429"),
"databricks/databricks-gpt-6-1-sol": ("28.571", "142.857", "35.714", "1.429"),
"databricks/databricks-gpt-6-1-terra": ("14.286", "71.429", "17.857", "0.714"),
"databricks/databricks-gpt-6-astra": ("142.857", "714.286", "178.571", "14.286"),
"databricks/databricks-gpt-6-luna": ("1.429", "7.143", "1.786", "0.143"),
"databricks/databricks-gpt-6-sol": ("28.571", "142.857", "35.714", "2.857"),
"databricks/databricks-claude-opus-5-5": ("57.143", "285.714", "71.429", "2.857"),
"databricks/databricks-claude-fable-5-1": ("142.858", "714.286", "178.572", "3.572"),
"databricks/databricks-claude-fable-5": ("142.858", "714.286", "178.572", "14.286"),
@ -43,7 +50,7 @@ PUBLISHED_DBU_PER_MILLION: Final = {
"databricks/databricks-claude-opus-4-5": ("71.429", "357.143", "89.286", "7.143"),
"databricks/databricks-claude-opus-4-1": ("214.286", "1071.429", "267.857", "21.429"),
"databricks/databricks-claude-opus-4": ("214.286", "1071.429", "267.857", "21.429"),
"databricks/databricks-claude-sonnet-5": ("42.857", "214.286", "53.571", "4.286"),
"databricks/databricks-claude-sonnet-5": ("28.571", "142.857", "35.714", "2.857"),
"databricks/databricks-claude-sonnet-4-6": ("42.857", "214.286", "53.571", "4.286"),
"databricks/databricks-claude-sonnet-4-5": ("42.857", "214.286", "53.571", "4.286"),
"databricks/databricks-claude-sonnet-4-1": ("42.857", "214.286", "53.571", "4.286"),
@ -197,7 +204,7 @@ def test_models_without_a_cache_discount_bill_cache_tokens_at_the_input_rate(
assert prompt_cost > 8000 * info["input_cost_per_token"]
def test_every_model_without_published_cache_dbu_bills_cache_at_its_own_input_rate(
def test_every_model_without_published_cache_dbu_bills_cache_writes_at_its_own_input_rate(
local_model_cost_map: None,
) -> None:
without_published_rates: Final = [
@ -210,8 +217,7 @@ def test_every_model_without_published_cache_dbu_bills_cache_at_its_own_input_ra
for model in without_published_rates:
info = _model_info(model)
for field in CACHE_FIELDS:
assert info[field] == pytest.approx(info["input_cost_per_token"]), (model, field)
assert info["cache_creation_input_token_cost"] == pytest.approx(info["input_cost_per_token"]), model
@pytest.mark.parametrize("model", NEW_MODELS)
@ -224,14 +230,6 @@ def test_backup_price_map_matches_main(model: str) -> None:
assert backup_cost[model] == main_cost[model]
def test_sonnet_5_ships_standard_rates_not_introductory(local_model_cost_map: None) -> None:
sonnet_5: Final = _model_info("databricks/databricks-claude-sonnet-5")
sonnet_4_6: Final = _model_info("databricks/databricks-claude-sonnet-4-6")
for field in PRICE_FIELDS:
assert sonnet_5[field] == pytest.approx(sonnet_4_6[field]), field
def test_cost_per_token_bills_the_served_priority_tier(
local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
) -> None:

View file

@ -0,0 +1,239 @@
import json
from collections.abc import Mapping
from typing import Final
import httpx
import pytest
import respx
from openai import OpenAI
import litellm
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.proxy.auth.model_checks import get_provider_models
ZEROGPU_API_BASE: Final = "https://api.zerogpu.ai/v1"
ZEROGPU_MODELS: Final = (
"deepseek-v4.1-flash",
"glm-5.3-flash",
"gpt-4.1-mini",
"gpt-5.4-nano",
"gpt-5.6-luna",
"gpt-6-luna",
"gpt-oss-120b",
"LFM2.5-1.2B-Instruct",
"LFM2.5-1.2B-Thinking",
"llama-3.1-8b-instruct-fast",
"llama-3.2-3b-instruct-fast",
"qwen3-30b-a3b-fp8",
"qwen3.6-35b-a3b",
)
WEATHER_TOOL: Final = {
"type": "function",
"function": {
"name": "get_weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
},
}
class _RecordingZeroGPU:
def __init__(self, usage: Mapping[str, object]) -> None:
self.usage: Final = usage
self.requests: tuple[httpx.Request, ...] = ()
def respond(self, request: httpx.Request) -> httpx.Response:
self.requests = (*self.requests, request)
return httpx.Response(
200,
json={
"id": "chatcmpl-zerogpu",
"object": "chat.completion",
"created": 1234567890,
"model": json.loads(request.content)["model"],
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
"usage": dict(self.usage),
},
)
def client(self) -> OpenAI:
return OpenAI(
api_key="zerogpu-test-key",
base_url=ZEROGPU_API_BASE,
http_client=httpx.Client(transport=httpx.MockTransport(self.respond)),
)
def test_prefixed_model_reaches_zerogpu_chat_completions_without_the_prefix():
zerogpu: Final = _RecordingZeroGPU({"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4})
with zerogpu.client() as client:
litellm.completion(
model="zerogpu/gpt-oss-120b",
messages=[{"role": "user", "content": "hello"}],
client=client,
)
assert len(zerogpu.requests) == 1
assert str(zerogpu.requests[0].url) == f"{ZEROGPU_API_BASE}/chat/completions"
assert json.loads(zerogpu.requests[0].content) == {
"model": "gpt-oss-120b",
"messages": [{"role": "user", "content": "hello"}],
}
def test_responses_request_reaches_zerogpu_responses_natively():
with respx.mock() as upstream:
route: Final = upstream.post(f"{ZEROGPU_API_BASE}/responses").respond(
200,
json={
"id": "resp_zerogpu",
"object": "response",
"created_at": 1_792_000_000,
"model": "gpt-oss-120b",
"status": "completed",
"output": [
{
"id": "msg_zerogpu",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "hi", "annotations": []}],
}
],
"usage": {"input_tokens": 3, "output_tokens": 1, "total_tokens": 4},
},
)
response: Final = litellm.responses(model="zerogpu/gpt-oss-120b", input="hello", api_key="zerogpu-test-key")
body: Final = json.loads(route.calls.last.request.content)
assert body["model"] == "gpt-oss-120b"
assert body["input"] == "hello"
assert response.output[0].content[0].text == "hi"
def test_anthropic_messages_request_is_bridged_to_zerogpu_chat_completions():
zerogpu: Final = _RecordingZeroGPU({"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4})
with zerogpu.client() as client:
response: Final = litellm.anthropic.messages.create(
model="zerogpu/gpt-oss-120b",
messages=[{"role": "user", "content": "hello"}],
max_tokens=10,
client=client,
)
assert [str(request.url) for request in zerogpu.requests] == [f"{ZEROGPU_API_BASE}/chat/completions"]
assert json.loads(zerogpu.requests[0].content) == {
"model": "gpt-oss-120b",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 10,
}
assert response["content"] == [{"type": "text", "text": "hi"}]
@pytest.mark.parametrize("model", ZEROGPU_MODELS)
def test_zerogpu_completion_is_charged_at_the_cost_map_rates(model: str):
usage: Final = {
"prompt_tokens": 1000,
"completion_tokens": 200,
"total_tokens": 1200,
"prompt_tokens_details": {"cached_tokens": 400},
}
zerogpu: Final = _RecordingZeroGPU(usage)
with zerogpu.client() as client:
response: Final = litellm.completion(
model=f"zerogpu/{model}",
messages=[{"role": "user", "content": "hello"}],
client=client,
)
rates: Final = litellm.model_cost[f"zerogpu/{model}"]
cached_rate: Final = rates.get("cache_read_input_token_cost", rates["input_cost_per_token"])
expected_cost: Final = (
600 * rates["input_cost_per_token"] + 400 * cached_rate + 200 * rates["output_cost_per_token"]
)
assert response._hidden_params["response_cost"] == pytest.approx(expected_cost)
assert expected_cost > 0
@pytest.mark.parametrize("model", ZEROGPU_MODELS)
def test_trim_messages_fits_zerogpu_history_into_the_model_context_window(model: str):
budget: Final = 20
trim_ratio: Final = budget / litellm.model_cost[f"zerogpu/{model}"]["max_input_tokens"]
messages: Final = [{"role": "user", "content": "lorem ipsum " * 300}]
trimmed: Final = litellm.utils.trim_messages(messages, model=f"zerogpu/{model}", trim_ratio=trim_ratio)
trimmed_tokens: Final = litellm.token_counter(model=f"zerogpu/{model}", messages=trimmed)
assert trimmed_tokens <= budget, trimmed
@pytest.mark.parametrize(
("model", "forwards_tools", "forwards_reasoning_effort"),
[
("gpt-5.6-luna", True, True),
("gpt-oss-120b", True, True),
("qwen3.6-35b-a3b", True, True),
("LFM2.5-1.2B-Instruct", False, False),
],
)
def test_tools_and_reasoning_effort_reach_zerogpu_only_for_models_that_support_them(
model: str, forwards_tools: bool, forwards_reasoning_effort: bool
):
zerogpu: Final = _RecordingZeroGPU({"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4})
with zerogpu.client() as client:
litellm.completion(
model=f"zerogpu/{model}",
messages=[{"role": "user", "content": "weather in Paris?"}],
tools=[WEATHER_TOOL],
tool_choice="auto",
reasoning_effort="high",
drop_params=True,
client=client,
)
tools: Final = {"tools": [WEATHER_TOOL], "tool_choice": "auto"} if forwards_tools else {}
reasoning: Final = {"reasoning_effort": "high"} if forwards_reasoning_effort else {}
assert json.loads(zerogpu.requests[0].content) == {
"model": model,
"messages": [{"role": "user", "content": "weather in Paris?"}],
**tools,
**reasoning,
}
@pytest.mark.parametrize("model", ZEROGPU_MODELS)
def test_model_info_reports_the_reasoning_support_litellm_routes_with(model: str):
model_info: Final = litellm.get_model_info(f"zerogpu/{model}")
assert bool(model_info.get("supports_reasoning")) == litellm.utils.supports_reasoning(f"zerogpu/{model}")
def test_zerogpu_key_and_base_come_from_zerogpu_env_vars(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("ZEROGPU_API_KEY", "zerogpu-env-key")
monkeypatch.delenv("ZEROGPU_API_BASE", raising=False)
assert get_llm_provider(model="zerogpu/gpt-oss-120b") == (
"gpt-oss-120b",
"zerogpu",
"zerogpu-env-key",
ZEROGPU_API_BASE,
)
monkeypatch.setenv("ZEROGPU_API_BASE", "https://zerogpu.internal.example/v1")
assert get_llm_provider(model="zerogpu/gpt-oss-120b")[3] == "https://zerogpu.internal.example/v1"
def test_zerogpu_wildcard_lists_every_zerogpu_catalog_model():
catalog_models: Final = sorted(
key for key, row in litellm.model_cost.items() if row.get("litellm_provider") == "zerogpu"
)
assert catalog_models
assert sorted(get_provider_models("zerogpu") or []) == catalog_models

View file

@ -3,13 +3,15 @@ import os
import stat
import time
from pathlib import Path
from typing import Final
from unittest.mock import Mock, patch
import pytest
import requests
import responses
from click.testing import CliRunner
from responses import matchers
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
from litellm.litellm_core_utils.cli_keyring import (
DISABLE_KEYRING_ENV_VAR,
KeyringDisabled,
@ -56,6 +58,20 @@ def _write_token_file(home: Path, *, key: str | None) -> None:
_write_home_json(home, "token.json", payload if key is None else {**payload, "key": key})
STALE_TOKEN_BASE_URL: Final = "https://stale-token.example.com"
STALE_TOKEN_TIMESTAMP: Final = 1_700_000_000.0
def _register_models_answer(key: str, answer: int | Exception) -> None:
models_url: Final = f"{STALE_TOKEN_BASE_URL}/v1/models"
authorized: Final = [matchers.header_matcher({"Authorization": f"Bearer {key}"})]
if isinstance(answer, Exception):
responses.get(models_url, body=answer, match=authorized)
else:
responses.get(models_url, json={"data": []}, status=answer, match=authorized)
responses.get(models_url, status=401)
def _secret_blob(base_url: str, key: str) -> str:
return json.dumps({"base_url": base_url, "key": key, "jwt_token": ""})
@ -696,22 +712,32 @@ class TestWhoamiCommand:
assert "Not authenticated" in result.output
assert "Run 'lite login'" in result.output
def test_whoami_old_token(self):
"""Test whoami with old token showing warning"""
token_data = CliTokenRecord(
@pytest.mark.parametrize(
("proxy_status", "expected_last_line_start"),
[
(200, "Token age: "),
(401, "Token expired. Run 'lite login' again."),
],
)
@responses.activate
def test_whoami_lets_the_proxy_judge_a_token_older_than_the_client_window(
self, proxy_status: int, expected_last_line_start: str
):
_register_models_answer("sk-live", proxy_status)
token_data: Final = CliTokenRecord(
base_url=STALE_TOKEN_BASE_URL,
user_email="test@example.com",
user_id="test-user-123",
user_role="admin",
key="sk-live",
timestamp=time.time() - (25 * 3600),
timestamp=STALE_TOKEN_TIMESTAMP,
)
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=token_data):
result = self.runner.invoke(whoami)
result: Final = self.runner.invoke(whoami)
assert result.exit_code == 0
assert "Authenticated" in result.output
assert "Warning: Token is more than 24 hours old" in result.output
assert result.exit_code == 0
assert result.output.splitlines()[-1].startswith(expected_last_line_start)
assert "Warning" not in result.output
assert len(responses.calls) == 1
def test_whoami_missing_fields(self):
"""Test whoami with token missing some fields"""
@ -1024,51 +1050,93 @@ class TestPrintTokenCommand:
assert result.exit_code == 0
assert result.output.strip() == "sk-matches"
@responses.activate
def test_fresh_cached_key_printed_without_network_call(self):
"""A recently-issued key should be printed straight from cache -- no
refresh call on every single invocation (apiKeyHelper gets called
frequently)."""
with (
patch(
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(
base_url="http://localhost:4000",
key="sk-cached-fresh",
timestamp=time.time(),
),
with patch(
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(
base_url="http://localhost:4000",
key="sk-cached-fresh",
timestamp=time.time(),
),
patch("requests.post") as mock_post,
):
result = self.runner.invoke(print_token, obj={})
result: Final = self.runner.invoke(print_token, obj={})
assert result.exit_code == 0
assert result.output.strip() == "sk-cached-fresh"
mock_post.assert_not_called()
assert len(responses.calls) == 0
def test_stale_key_fails_fast_without_network_call(self):
"""There is no silent refresh: an expired cached key must fail
loudly (stderr, nonzero exit) telling the user to `lite login`
again, rather than making a network call or printing a dead key
that will just 401 Claude Code."""
old_timestamp = time.time() - (CLI_JWT_EXPIRATION_HOURS + 1) * 3600
@pytest.mark.parametrize(
("proxy_answer", "expected_exit_code", "expected_stdout", "expected_stderr"),
[
(200, 0, "sk-stale-key\n", ""),
(401, 1, "", "Token expired. Run 'lite login' again.\n"),
(403, 1, "", "Token expired. Run 'lite login' again.\n"),
(404, 0, "sk-stale-key\n", ""),
(500, 0, "sk-stale-key\n", ""),
(requests.ConnectionError("proxy unreachable"), 0, "sk-stale-key\n", ""),
],
)
@responses.activate
def test_key_older_than_the_client_window_is_judged_by_the_proxy(
self,
proxy_answer: int | Exception,
expected_exit_code: int,
expected_stdout: str,
expected_stderr: str,
):
_register_models_answer("sk-stale-key", proxy_answer)
with (
patch(
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(
base_url="http://localhost:4000",
key="sk-stale-key",
timestamp=old_timestamp,
),
with patch(
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(
base_url=STALE_TOKEN_BASE_URL,
key="sk-stale-key",
timestamp=STALE_TOKEN_TIMESTAMP,
),
patch("requests.post") as mock_post,
):
result = self.runner.invoke(print_token, obj={})
result: Final = self.runner.invoke(print_token, obj={})
assert result.exit_code != 0
assert "sk-stale-key" not in result.output
assert "lite login" in result.output
mock_post.assert_not_called()
assert result.exit_code == expected_exit_code
assert result.stdout == expected_stdout
assert result.stderr == expected_stderr
assert len(responses.calls) == 1
@responses.activate
def test_proxy_check_waits_only_a_few_seconds_for_a_silent_proxy(self):
_register_models_answer("sk-stale-key", requests.ReadTimeout("proxy sent nothing"))
with patch(
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(
base_url=STALE_TOKEN_BASE_URL,
key="sk-stale-key",
timestamp=STALE_TOKEN_TIMESTAMP,
),
):
result: Final = self.runner.invoke(print_token, obj={})
timeout: Final = responses.calls[0].request.req_kwargs["timeout"]
assert result.exit_code == 0
assert result.stdout == "sk-stale-key\n"
assert isinstance(timeout, int | float)
assert 0 < timeout <= 5
@responses.activate
def test_key_without_a_recorded_proxy_still_expires_after_the_client_window(self):
with patch(
"litellm.proxy.client.cli.commands.auth.load_cli_token",
return_value=CliTokenRecord(key="sk-old-format", timestamp=STALE_TOKEN_TIMESTAMP),
):
result: Final = self.runner.invoke(print_token, obj={})
assert result.exit_code == 1
assert result.stdout == ""
assert result.stderr == "Token expired. Run 'lite login' again.\n"
assert len(responses.calls) == 0
class TestPrintTokenWithConfigFile:
@ -2019,18 +2087,27 @@ class TestPkcePrintToken:
assert sum(len(session.posts) for session in _FakeSession.instances) == 1
assert save.call_count == 1
def test_print_token_for_an_expired_classic_token_makes_no_request(self):
@responses.activate
def test_print_token_trusts_a_recorded_expiry_without_asking_the_proxy(self):
_register_models_answer("sk-classic", 200)
with (
patch(
"litellm.proxy.client.cli.commands.auth.load_token",
return_value={"key": "sk-classic", "timestamp": time.time() - (CLI_JWT_EXPIRATION_HOURS + 1) * 3600},
return_value={
"key": "sk-classic",
"base_url": STALE_TOKEN_BASE_URL,
"timestamp": STALE_TOKEN_TIMESTAMP,
"expires_at": STALE_TOKEN_TIMESTAMP + 3600,
},
),
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _FakeSession),
):
result = self.runner.invoke(print_token, obj={})
result: Final = self.runner.invoke(print_token, obj={})
assert result.exit_code == 1
assert "Token expired" in result.output
assert result.stdout == ""
assert result.stderr == "Token expired. Run 'lite login' again.\n"
assert len(responses.calls) == 0
assert _FakeSession.instances == []

View file

@ -1834,3 +1834,56 @@ def test_proxy_routes_lm_studio_decisions_with_optional_authentication(
else [{"type": "predicate", "name": "is_defect", "probability": 0.9}]
)
assert response.json()["answers"] == expected
@pytest.mark.parametrize("provider", ("ollama", "ollama_chat"))
@pytest.mark.parametrize("endpoint", ("/v1/systemone", "/v1/decisions"))
@pytest.mark.parametrize("api_key", (None, "deployment-key"))
def test_proxy_routes_ollama_decisions_with_optional_authentication(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
provider: str,
endpoint: str,
api_key: str | None,
) -> None:
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
monkeypatch.delenv("OLLAMA_API_BASE", raising=False)
deployment_router: Final = litellm.Router(
model_list=[
{
"model_name": "local-decision",
"litellm_params": {
"model": f"{provider}/d3:4b",
"api_base": "http://ollama.local:11434/v1",
"api_key": api_key,
},
}
]
)
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", deployment_router)
upstream: Final = respx_mock.post("http://ollama.local:11434/v1/systemone").respond(json=_RESPONSE)
fields: Final = (
{"state": "review", "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}}
if endpoint.endswith("systemone")
else {
"input": "review",
"questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
}
)
response: Final = client.post(endpoint, json={"model": "local-decision", **fields})
assert response.status_code == 200, response.text
assert json.loads(upstream.calls.last.request.content) == {
"model": "d3:4b",
"state": "review",
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
}
assert upstream.calls.last.request.headers.get("authorization") == (f"Bearer {api_key}" if api_key else None)
expected: Final = (
_RESPONSE["answers"]
if endpoint.endswith("systemone")
else [{"type": "predicate", "name": "is_defect", "probability": 0.9}]
)
assert response.json()["answers"] == expected

View file

@ -8,12 +8,18 @@ query parameter. Flat credential query params (e.g. ?api_key=…) are no
longer accepted — they would appear in server logs.
"""
import asyncio
import json
from pathlib import Path
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import respx
from fastapi import Request
from fastapi.datastructures import Headers, QueryParams
from fastapi.testclient import TestClient
from litellm.proxy.google_endpoints.agents_endpoints import (
@ -470,3 +476,41 @@ def test_validate_environment_env_fallback_when_no_api_base_override(monkeypatch
cfg = GeminiAgentsConfig()
headers = cfg.validate_environment(headers={}, litellm_params={})
assert headers["x-goog-api-key"] == "AIzaFromEnv"
_PROXY_CONFIG: Final = Path(__file__).resolve().parents[1] / "test_configs" / "test_bad_config.yaml"
@pytest.fixture
def proxy_client(monkeypatch: pytest.MonkeyPatch, httpx_transport: None) -> TestClient:
from litellm.proxy.proxy_server import app, cleanup_router_config_variables, initialize
monkeypatch.setenv("LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY", "true")
cleanup_router_config_variables()
asyncio.run(initialize(config=str(_PROXY_CONFIG)))
return TestClient(app)
def test_create_agent_route_forwards_googles_create_body(proxy_client: TestClient, respx_mock: respx.MockRouter):
google_body: Final = {
"id": "sentinel",
"base_agent": "antigravity-preview-09-2026",
"agent_config": {"type": "antigravity", "model": "gemini-3.8-flash"},
"system_instruction": "Always answer in exactly three words.",
"base_environment": {"type": "remote"},
"description": "Watches the queue.",
"tools": [{"type": "code_execution"}],
}
upstream: Final = respx_mock.post("https://generativelanguage.googleapis.com/v1beta/agents").mock(
return_value=httpx.Response(200, json=google_body)
)
response: Final = proxy_client.post(
"/v1beta/agents",
json={**google_body, "litellm_params_template": {"api_key": "AIza-caller"}},
)
assert response.status_code == 200, response.text
assert json.loads(upstream.calls.last.request.content) == google_body
assert upstream.calls.last.request.headers["x-goog-api-key"] == "AIza-caller"
assert response.json()["id"] == "sentinel"

View file

@ -6,9 +6,16 @@ The proxy must not pass the agent name as ``model`` or LiteLLM may route to
openai/* wildcards instead of Gemini interactions.
"""
import asyncio
import json
from pathlib import Path
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
class TestInteractionsAgentParameter:
@ -101,3 +108,37 @@ class TestInteractionsAgentOnlyProviderRouting:
mock_get_llm_provider.assert_not_called()
assert mock_handler.create_interaction.call_args.kwargs["model"] is None
_PROXY_CONFIG: Final = Path(__file__).resolve().parents[1] / "test_configs" / "test_bad_config.yaml"
@pytest.fixture
def proxy_client(monkeypatch: pytest.MonkeyPatch, httpx_transport: None) -> TestClient:
from litellm.proxy.proxy_server import app, cleanup_router_config_variables, initialize
monkeypatch.setenv("LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY", "true")
cleanup_router_config_variables()
asyncio.run(initialize(config=str(_PROXY_CONFIG)))
return TestClient(app)
@pytest.mark.usefixtures("fake_provider_credentials")
def test_create_interaction_route_forwards_agent_config(proxy_client: TestClient, respx_mock: respx.MockRouter):
request_body: Final = {
"agent": "antigravity-preview-09-2026",
"agent_config": {"type": "antigravity", "model": "gemini-3.8-flash"},
"input": "Say hi in one word.",
"environment": "remote",
}
upstream: Final = respx_mock.post("https://generativelanguage.googleapis.com/v1beta/interactions").mock(
return_value=httpx.Response(
200,
json={"id": "interaction-agent", "object": "interaction", "status": "completed"},
)
)
response: Final = proxy_client.post("/v1beta/interactions", json=request_body)
assert response.status_code == 200, response.text
assert json.loads(upstream.calls.last.request.content) == request_body

View file

@ -15,28 +15,38 @@ Redis.
"""
import asyncio
import base64
import copy
import io
import json
from datetime import datetime, timedelta
from typing import Any, Dict, Final
import pytest
import soundfile as sf
from fastapi import HTTPException
from PIL import Image
from litellm import token_counter
from litellm.caching.caching import DualCache
from litellm.litellm_core_utils.token_counter import UnsupportedContentTypeError, estimate_image_tokens
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
_AUDIO_BYTES_PER_TOKEN,
DEFAULT_AUDIO_TOKEN_ESTIMATE,
PROJECT_ITPM_DESCRIPTOR_KEY,
PROJECT_OTPM_DESCRIPTOR_KEY,
RateLimitedModel,
_AUDIO_BYTES_PER_TOKEN,
PROXY_MaxParallelRequestsHandler_v3 as RateLimitHandler,
)
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
_call_id_from_callback_kwargs,
_request_stash,
get_or_create_request_stash,
get_request_stash,
)
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
PROXY_MaxParallelRequestsHandler_v3 as RateLimitHandler,
)
from litellm.proxy.utils import InternalUsageCache, hash_token
from litellm.router_utils.ptu_shares import PTUTeamCeiling
from litellm.types.llms.openai import (
InputTokensDetails,
ResponseAPIUsage,
@ -3775,3 +3785,284 @@ def test_reservation_counts_a_compaction_item_by_its_summary(rate_limiter):
) == handler._estimate_input_and_output_tokens(
data={"model": "gpt-4o", "input": _COMPACTED_INPUT[2:], "max_output_tokens": 1}, call_type="aresponses"
)
_QUOTA_TEXT: Final = "a" * 400
_QUOTA_JSON_TEXT: Final = '{"type":"input_image","image_url":"data:image/png;base64,AAAA"}'
@pytest.mark.parametrize(
("call_type", "request_data", "expected"),
[
("acompletion", {"messages": [{"role": "user", "content": _QUOTA_TEXT}], "max_tokens": 16}, (100, 16)),
("acompletion", {"messages": [{"role": "user", "content": _QUOTA_JSON_TEXT}], "max_tokens": 16}, (15, 16)),
(
"anthropic_messages",
{
"messages": [
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "t",
"content": [{"type": "text", "text": _QUOTA_TEXT}],
}
],
}
],
"max_tokens": 16,
},
(0, 16),
),
(
"acompletion",
{
"messages": [
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "t", "type": "function", "function": {"name": "f", "arguments": _QUOTA_TEXT}}
],
}
],
"max_tokens": 16,
},
(0, 16),
),
("aresponses", {"input": _QUOTA_TEXT, "max_output_tokens": 16}, (100, 16)),
("aresponses", {"input": _QUOTA_TEXT, "instructions": "b" * 400, "max_output_tokens": 16}, (100, 16)),
(
"aresponses",
{"input": [{"role": "user", "content": _QUOTA_TEXT}], "instructions": "b" * 400, "max_output_tokens": 16},
(107, 16),
),
(
"aresponses",
{
"input": [
{
"type": "file_search_call",
"id": "f",
"status": "completed",
"queries": ["q"],
"results": [
{
"file_id": "file_1",
"filename": "result.txt",
"attributes": {},
"score": 1,
"text": _QUOTA_TEXT,
}
],
}
],
"max_output_tokens": 16,
},
(146, 16),
),
(
"aresponses",
{
"input": [{"type": "custom_tool_call_output", "call_id": "t", "output": _QUOTA_JSON_TEXT}],
"max_output_tokens": 16,
},
(32, 16),
),
("aresponses", {"input": "", "instructions": "b" * 400}, (0, 0)),
("aembedding", {"input": [_QUOTA_TEXT]}, (100, 0)),
("atext_completion", {"prompt": [_QUOTA_TEXT], "max_tokens": 16}, (100, 16)),
],
)
def test_media_correction_preserves_exact_non_media_reservations(
rate_limiter: tuple[RateLimitHandler, DualCache],
call_type: str,
request_data: dict[str, object],
expected: tuple[int, int],
) -> None:
handler, _ = rate_limiter
assert handler._estimate_input_and_output_tokens(request_data, call_type=call_type) == expected
def _quota_media_payload(kind: str, compressed: bool) -> str:
output: Final = io.BytesIO()
if kind == "image":
Image.new("RGB", (1024, 1024)).save(output, format="PNG", compress_level=9 if compressed else 0)
else:
sample_rate: Final = 8000 if compressed else 48000
with sf.SoundFile(output, "w", samplerate=sample_rate, channels=1, format="WAV") as audio:
audio.buffer_write(b"\0" * (sample_rate * 2), dtype="int16")
return base64.b64encode(output.getvalue()).decode()
def _quota_media_request(kind: str, payload: str, call_type: str) -> dict[str, object]:
block: Final = (
{"type": "input_audio", "input_audio": {"data": payload, "format": "wav"}}
if kind == "audio"
else {"type": "input_image", "image_url": "data:image/png;base64," + payload}
if call_type == "aresponses"
else {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": payload}}
if call_type == "anthropic_messages"
else {"type": "image_url", "image_url": {"url": "data:image/png;base64," + payload}}
)
content: Final = (
[{"type": "tool_result", "tool_use_id": "t", "content": [block]}]
if call_type == "anthropic_messages"
else [block]
)
field: Final = "input" if call_type == "aresponses" else "messages"
return {
"model": "gpt-6-astra",
field: [{"role": "user", "content": content}],
"max_tokens": 16,
"max_output_tokens": 16,
}
@pytest.mark.parametrize(
("kind", "call_type"),
[
("image", "acompletion"),
("image", "aresponses"),
("image", "anthropic_messages"),
("audio", "acompletion"),
("audio", "aresponses"),
],
)
def test_media_reservation_is_independent_of_encoding_and_preserves_request(
rate_limiter: tuple[RateLimitHandler, DualCache], kind: str, call_type: str
) -> None:
handler, _ = rate_limiter
compressed: Final = _quota_media_request(kind, _quota_media_payload(kind, True), call_type)
uncompressed: Final = _quota_media_request(kind, _quota_media_payload(kind, False), call_type)
original: Final = copy.deepcopy(uncompressed)
estimate: Final = handler._estimate_input_and_output_tokens(uncompressed, call_type=call_type)
assert estimate == handler._estimate_input_and_output_tokens(compressed, call_type=call_type)
assert estimate[0] > 0 and estimate[1] == 16
assert uncompressed == original
@pytest.mark.asyncio
@pytest.mark.parametrize("call_type", ["acompletion", "aresponses"])
@pytest.mark.parametrize("limit", [1000, 20000])
async def test_media_pre_call_rejects_tight_quota_and_reserves_sufficient_quota(
rate_limiter: tuple[RateLimitHandler, DualCache], call_type: str, limit: int
) -> None:
handler, cache = rate_limiter
request: Final = {
key: value
for key, value in _quota_media_request("image", _quota_media_payload("image", True), call_type).items()
if key not in ("max_tokens", "max_output_tokens")
}
auth: Final = UserAPIKeyAuth(api_key=hash_token("sk-media-quota"), tpm_limit=limit)
if limit == 1000:
with pytest.raises(HTTPException) as error:
await handler.async_pre_call_hook(user_api_key_dict=auth, cache=cache, data=request, call_type=call_type)
assert error.value.status_code == 429
return
await handler.async_pre_call_hook(user_api_key_dict=auth, cache=cache, data=request, call_type=call_type)
stash: Final = get_request_stash()
assert stash is not None
expected: Final = handler._estimate_input_and_output_tokens(
request, call_type=call_type, min_configured_tpm_limit=limit
)
assert stash.reserved_tokens == sum(expected)
assert expected[0] > 1000 and expected[1] >= expected[0]
@pytest.mark.asyncio
async def test_ptu_pre_call_weights_media_input_and_output() -> None:
cache: Final = DualCache()
ceiling: Final = PTUTeamCeiling(
model_group="gpt-6-astra", tpm_limit=10000, output_to_input_ratio=4, cached_input_ratio=0
)
def resolve_ceiling(team: str, model: str) -> PTUTeamCeiling | None:
return ceiling if (team, model) == ("test-team", "gpt-6-astra") else None
handler: Final = RateLimitHandler(
internal_usage_cache=InternalUsageCache(cache), ptu_team_ceiling_resolver=resolve_ceiling
)
request: Final = _quota_media_request("image", _quota_media_payload("image", True), "acompletion")
await handler.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(api_key=hash_token("sk-media-ptu"), team_id="test-team"),
cache=cache,
data=request,
call_type="acompletion",
)
stash: Final = get_request_stash()
assert stash is not None
assert (
stash.ptu_reserved_tokens
== estimate_image_tokens("gpt-6-astra", "data:image/png;base64," + _quota_media_payload("image", True)) + 4 * 16
)
@pytest.mark.asyncio
async def test_project_fallback_keeps_legacy_audio_estimate_and_output_policy(
rate_limiter: tuple[RateLimitHandler, DualCache],
) -> None:
handler, cache = rate_limiter
unsupported: Final = {"type": "unsupported_content"}
with pytest.raises(UnsupportedContentTypeError):
token_counter(model="gpt-6-astra", messages=[{"role": "user", "content": [unsupported]}])
request: Final = {
"model": "gpt-6-astra",
"messages": [
{
"role": "user",
"content": [
unsupported,
{
"type": "input_audio",
"input_audio": {"data": _quota_media_payload("audio", True), "format": "wav"},
},
],
}
],
}
auth: Final = UserAPIKeyAuth(
api_key=hash_token("sk-media-project"),
project_id="media-project",
project_metadata={"model_itpm_limit": {"gpt-6-astra": 1000}, "model_otpm_limit": {"gpt-6-astra": 1000}},
)
await handler.async_pre_call_hook(user_api_key_dict=auth, cache=cache, data=request, call_type="acompletion")
stash: Final = get_request_stash()
assert stash is not None
assert stash.itpm_reserved_tokens == DEFAULT_AUDIO_TOKEN_ESTIMATE
assert stash.otpm_reserved_tokens == 1
@pytest.mark.asyncio
async def test_single_anthropic_image_exceeds_tight_quota_with_explicit_output_cap(
rate_limiter: tuple[RateLimitHandler, DualCache],
) -> None:
handler, cache = rate_limiter
request: Final = {
"model": "anthropic/claude-haiku-5-5",
"messages": [
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": _quota_media_payload("image", True),
},
}
],
}
],
"max_tokens": 64,
}
with pytest.raises(HTTPException) as error:
await handler.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(api_key=hash_token("sk-claude-media-quota"), tpm_limit=500),
cache=cache,
data=request,
call_type="anthropic_messages",
)
assert error.value.status_code == 429

View file

@ -488,22 +488,17 @@ async def test_otel_unhandled_exception_handler_reraises_http_exception_invalid(
@pytest.mark.asyncio
@pytest.mark.parametrize("media_type", ["application/json", "application/x-protobuf"])
@pytest.mark.parametrize("root_path", ["", "/tenant-a"])
@pytest.mark.parametrize("native_available", [True, False])
@pytest.mark.parametrize("error", [
ProxyException("database credentials: secret", "auth_error", None, 401),
HTTPException(403, "database credentials: secret"),
])
async def test_otlp_auth_errors_hide_internal_details_and_survive_missing_native(
media_type: str, root_path: str, native_available: bool,
error: ProxyException | HTTPException, monkeypatch: pytest.MonkeyPatch,
async def test_otlp_auth_errors_hide_internal_details(
media_type: str, root_path: str, error: ProxyException | HTTPException,
) -> None:
from google.rpc.status_pb2 import Status
from litellm.proxy.proxy_server import otlp_http_exception_handler
from litellm.rust_bridge import loader
if not native_available:
monkeypatch.setattr(loader, "_cached_bridge", None)
request: Final = Request({
"type": "http", "method": "POST", "path": root_path + "/v1/traces", "root_path": root_path,
"headers": [(b"content-type", media_type.encode())],
@ -522,7 +517,7 @@ async def test_otlp_auth_errors_hide_internal_details_and_survive_missing_native
else Status.FromString(response.body).message
)
expected: Final = "Unauthorized" if isinstance(error, ProxyException) else "Forbidden"
assert message == (expected if native_available or media_type == "application/json" else "")
assert message == expected
def _websocket(sent: list[Message]) -> WebSocket:

View file

@ -363,6 +363,26 @@ def test_cognition_provider_fields():
assert fields_by_key["api_base"]["required"] is False
def test_zerogpu_add_model_form_defaults_resolve_to_a_priced_zerogpu_model():
import litellm
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
app_instance: Final = FastAPI()
app_instance.include_router(router)
providers: Final = TestClient(app_instance).get("/public/providers/fields").json()
zerogpu: Final = next(p for p in providers if p["litellm_provider"] == "zerogpu")
fields_by_key: Final = {f["key"]: f for f in zerogpu["credential_fields"]}
placeholder: Final = zerogpu["default_model_placeholder"]
_, provider, _, api_base = get_llm_provider(model=placeholder)
assert provider == "zerogpu"
assert api_base == fields_by_key["api_base"]["placeholder"]
assert litellm.model_cost[placeholder]["litellm_provider"] == "zerogpu"
assert fields_by_key["api_key"]["required"] is True
assert fields_by_key["api_key"]["field_type"] == "password"
def test_qwen_mainland_provider_fields_carry_the_qianwen_brand():
app_instance = FastAPI()
app_instance.include_router(router)

View file

@ -1494,7 +1494,7 @@ async def test_route_request_keeps_key_fallbacks_out_of_pass_through_provider_bo
("avideo_create_character", "name", "/videos/characters"),
("acreate_container", "name", "/containers"),
("aupload_container_file", "file", "/containers/{container_id}/files"),
("acreate_agent", "name", "/v1beta/agents"),
("acreate_agent", "id", "/v1beta/agents"),
("acreate_eval", "data_source_config", "/evals"),
("acreate_run", "data_source", "/evals/{eval_id}/runs"),
],
@ -1699,7 +1699,7 @@ def test_required_present_body_param_with_none_router_wide_default_still_raises(
("asearch", {"model": "search-model"}, "query"),
("acreate_eval", {"model": "eval-model"}, "data_source_config"),
("acreate_run", {"model": "eval-model"}, "data_source"),
("acreate_agent", {"model": "agent-model"}, "name"),
("acreate_agent", {"model": "agent-model"}, "id"),
("avector_store_search", {}, "query"),
],
)
@ -1842,6 +1842,8 @@ def test_raise_if_required_body_param_missing_names_each_missing_param(route_typ
("acreate_eval", {"data_source_config": {"type": "custom"}, "testing_criteria": []}),
("acreate_interaction", {"model": "gemini-3-pro-preview", "input": "hi"}),
("acreate_interaction", {"agent": "deep-research", "input": "hi"}),
("acreate_agent", {"id": "sentinel"}),
("acreate_agent", {"name": "sentinel"}),
("aimage_generation", {"model": "gpt-image-1", "prompt": "a cat"}),
("aspeech", {"model": "gpt-4o-mini-tts", "input": "hi", "voice": "alloy"}),
("amoderation", {"model": "omni-moderation-latest", "input": ""}),

View file

@ -30,7 +30,6 @@ from litellm.proxy.auth.authorization import OwnedRows, ReadScope
from litellm.proxy.auth.authorization_dependencies import get_log_team_lookup
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.tracing_runtime import manage_tracing, provide_storage
from litellm.rust_bridge import loader
from litellm.tracing.errors import TraceChanged
from litellm.tracing.generated.models import TraceQueryHelp
from litellm.tracing.generated.responses import TraceSQLResponse

View file

@ -55,21 +55,6 @@ async def test_adapter_raises_clean_close_when_rust_connection_ends() -> None:
await adapter.recv()
@pytest.mark.asyncio
async def test_bridge_unavailable_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(responses_websocket, "_STATE", responses_websocket._RustResponsesWebSocketState())
monkeypatch.setattr(responses_websocket, "get_native_bridge", lambda: None)
assert (
await responses_websocket.connect(
url="wss://example.test/responses",
headers={},
timeout=None,
)
is None
)
@pytest.mark.asyncio
async def test_enabled_bridge_connects_and_adapts_socket(
monkeypatch: pytest.MonkeyPatch,

View file

@ -17,6 +17,7 @@ from botocore.credentials import Credentials
from pydantic import JsonValue
import litellm
from litellm.rust_bridge import _native as native
from litellm.rust_bridge.host.secret_manager import capture_secret_manager, native_secret_manager_config
from litellm.secret_managers.aws_secret_manager_v2 import AWSSecretsManagerV2
from litellm.secret_managers.cyberark_secret_manager import CyberArkSecretManager
@ -67,7 +68,6 @@ def _vault_body(value: str) -> dict[str, object]:
def _native_handle(client: object) -> "_SecretManagerRuntime":
native: Final = pytest.importorskip("litellm.rust_bridge._native")
handle: Final = native._SecretManagerRuntime.from_client(client)
assert handle is not None
return handle
@ -75,7 +75,6 @@ def _native_handle(client: object) -> "_SecretManagerRuntime":
@pytest.mark.parametrize("system", ("aws_secret_manager", "hashicorp_vault", "cyberark"))
async def test_python_native_handle_reuses_backend_across_sync_and_async_reads(system: str) -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
environment: Final = {
"AWS_REGION_NAME": "us-east-1",
@ -109,7 +108,6 @@ async def test_python_native_handle_reuses_backend_across_sync_and_async_reads(s
def test_shared_initializer_captures_credentials_and_tracks_instance_settings(monkeypatch: pytest.MonkeyPatch) -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(body={"SecretString": "native-value"})
server.expected_requests = 2
@ -141,7 +139,6 @@ def test_shared_initializer_captures_credentials_and_tracks_instance_settings(mo
def test_configuration_replacement_rebuilds_without_invalidating_existing_handles(
monkeypatch: pytest.MonkeyPatch,
) -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as first_server, recording_service() as second_server:
first_server.default_response = ResponseSpec(body=_vault_body("first"))
second_server.default_response = ResponseSpec(body=_vault_body("second"))
@ -158,7 +155,6 @@ def test_configuration_replacement_rebuilds_without_invalidating_existing_handle
def test_custom_subclass_is_not_captured_for_a_native_handle() -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
class CustomManager(AWSSecretsManagerV2):
def sync_read_secret(self, secret_name: str, primary_secret_name: str | None = None) -> str:
@ -171,19 +167,16 @@ def test_custom_subclass_is_not_captured_for_a_native_handle() -> None:
@pytest.mark.parametrize("system", ("google_secret_manager", "hashicorp_vault", "cyberark"))
def test_enterprise_backends_cannot_initialize_without_entitlement(system: str) -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
with pytest.raises(ValueError, match=r"[Ee]nterprise|[Pp]remium"):
native._SecretManagerRuntime.from_config(system, {"CYBERARK_API_KEY": "key"})
def test_azure_factory_rejects_unencrypted_vault_endpoints() -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
with pytest.raises(ValueError, match="https"):
native._SecretManagerRuntime.from_config("azure_key_vault", {"AZURE_KEY_VAULT_URI": "http://127.0.0.1:1"})
def test_direct_builtin_constructor_can_use_native_without_registration(monkeypatch: pytest.MonkeyPatch) -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(body=_vault_body("native-value"))
manager: Final = _vault(monkeypatch, server.base_url)
@ -193,12 +186,10 @@ def test_direct_builtin_constructor_can_use_native_without_registration(monkeypa
def test_uncaptured_clients_have_no_native_handle() -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
assert native._SecretManagerRuntime.from_client(SimpleNamespace()) is None
def test_native_failure_is_not_replayed_in_python(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(status=403, body={"errors": ["denied"]})
server.expected_requests = 1
@ -267,7 +258,6 @@ def test_same_named_custom_client_is_not_captured() -> None:
@pytest.mark.parametrize("value", ("text", "", True, False, 42, 2**100, [1, "two"], {"nested": True}, None))
async def test_aws_primary_values_match_python_handler(monkeypatch: pytest.MonkeyPatch, value: JsonValue) -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(body={"SecretString": json.dumps({"KEY": value})})
server.expected_requests = 3
@ -290,7 +280,6 @@ async def test_aws_primary_values_match_python_handler(monkeypatch: pytest.Monke
@pytest.mark.parametrize("primary", (None, "primary"))
async def test_aws_primary_values_match_python_reads(monkeypatch: pytest.MonkeyPatch, primary: str | None) -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(body={"SecretString": '{"KEY":"value"}'})
server.expected_requests = 3
@ -343,7 +332,6 @@ def test_aws_absence_and_failed_reads_match_python_without_environment_fallback(
async def test_aws_primary_json_errors_preserve_python_exception_details(
monkeypatch: pytest.MonkeyPatch, document: str
) -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(body={"SecretString": document})
server.expected_requests = 3
@ -370,7 +358,6 @@ async def test_aws_primary_json_errors_preserve_python_exception_details(
async def test_public_aws_reads_preserve_coroutines_and_per_call_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as initial, recording_service() as selected:
initial.expected_requests = 0
selected.expected_requests = 2
@ -421,7 +408,6 @@ async def test_public_aws_reads_preserve_coroutines_and_per_call_credentials(
async def test_public_aws_primary_reads_ignore_operation_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server, recording_service() as unused:
server.default_response = ResponseSpec(body={"SecretString": '{"KEY":true}'})
server.expected_requests = 2
@ -440,7 +426,6 @@ async def test_public_aws_primary_reads_ignore_operation_overrides(monkeypatch:
async def test_public_aws_bootstrap_names_only_bypass_sync_reads(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(body={"SecretString": "remote-access"})
server.expected_requests = 1
@ -457,7 +442,6 @@ async def test_public_aws_bootstrap_names_only_bypass_sync_reads(monkeypatch: py
async def test_public_aws_read_timeouts_follow_the_python_http_handler(
monkeypatch: pytest.MonkeyPatch, timeout: float | httpx.Timeout
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(body={"SecretString": "too-late"}, delay=0.25)
server.expected_requests = 2
@ -470,7 +454,6 @@ async def test_public_aws_read_timeouts_follow_the_python_http_handler(
async def test_public_vault_reads_keep_overrides_cache_and_coroutines(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(body=_vault_body("value"))
server.expected_requests = 2
@ -490,7 +473,6 @@ async def test_public_vault_reads_keep_overrides_cache_and_coroutines(monkeypatc
async def test_public_vault_failed_reads_return_none_without_replay(
monkeypatch: pytest.MonkeyPatch, status: int
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(status=status, body={"errors": ["unavailable"]})
server.expected_requests = 2
@ -502,7 +484,6 @@ async def test_public_vault_failed_reads_return_none_without_replay(
async def test_public_cyberark_reads_reuse_authentication_and_cached_values(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
from litellm.proxy import proxy_server
from litellm.secret_managers.cyberark_secret_manager import CyberArkSecretManager
@ -536,7 +517,6 @@ def test_public_aws_bootstrap_read_does_not_hit_the_provider(monkeypatch: pytest
def test_public_vault_prefix_overrides_match_python_string_conversion(
monkeypatch: pytest.MonkeyPatch, override: str | int
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setenv("HCP_VAULT_PATH_PREFIX", "default-prefix")
with recording_service() as server:
server.default_response = ResponseSpec(body=_vault_body("value"))
@ -562,7 +542,6 @@ def _cyberark(monkeypatch: pytest.MonkeyPatch, address: str) -> CyberArkSecretMa
async def test_public_cyberark_writes_and_deletes_share_the_read_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
for body in (b"token", b"old", {}, {}, b"provider-after-delete"):
server.enqueue(ResponseSpec(body=body))
@ -609,7 +588,6 @@ async def test_public_cyberark_write_errors_match_python_without_http_retries(
status: int,
authentication: bool,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
responses: Final = (
(ResponseSpec(status=status, body={}),) * 2
@ -635,7 +613,6 @@ async def test_public_cyberark_write_errors_match_python_without_http_retries(
async def test_public_cyberark_write_recovers_from_initial_policy_authentication_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.enqueue(ResponseSpec(status=401, body={}))
server.enqueue(ResponseSpec(body=b"token"))
@ -658,7 +635,6 @@ async def test_public_cyberark_write_rejects_unsafe_names_before_authentication(
monkeypatch: pytest.MonkeyPatch,
name: str,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.expected_requests = 0
manager: Final = _cyberark(monkeypatch, server.base_url)
@ -673,7 +649,6 @@ async def test_public_cyberark_rotation_returns_the_write_response_and_retains_o
monkeypatch: pytest.MonkeyPatch,
same_name: bool,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
for body in (b"token", b"old-value", {}, {}):
server.enqueue(ResponseSpec(body=body))
@ -696,7 +671,6 @@ async def test_public_native_cyberark_rotation_requires_a_fresh_matching_replace
monkeypatch: pytest.MonkeyPatch,
replacement: bytes | None,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
for body in (b"token", b"old-value", {}, {}):
server.enqueue(ResponseSpec(body=body))
@ -720,7 +694,6 @@ async def test_public_native_cyberark_rotation_requires_a_fresh_matching_replace
async def test_public_cyberark_cached_authentication_does_not_retry_denied_reads(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.enqueue(ResponseSpec(body=b"token"))
server.enqueue(ResponseSpec(body=b"value"))
@ -734,7 +707,6 @@ async def test_public_cyberark_cached_authentication_does_not_retry_denied_reads
async def test_cyberark_handler_errors_match_python_after_cached_authentication_is_denied(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
for response in (
ResponseSpec(body=b"token"),
@ -757,7 +729,6 @@ async def test_cyberark_handler_errors_match_python_after_cached_authentication_
async def test_public_cyberark_connection_errors_match_python(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.expected_requests = 0
address: Final = server.base_url
@ -769,7 +740,6 @@ async def test_public_cyberark_connection_errors_match_python(
async def test_public_native_cyberark_rotation_stops_after_a_failed_write(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
for body in (b"token", b"old-value", {}):
server.enqueue(ResponseSpec(body=body))
@ -788,7 +758,6 @@ async def test_public_vault_writes_preserve_complete_responses_and_request_field
monkeypatch: pytest.MonkeyPatch,
description: str | None,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
response: Final = {
"request_id": "test-request",
@ -829,7 +798,6 @@ async def test_public_vault_mutation_http_errors_match_python_without_retry(
operation: str,
status: int,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(status=status, body={"errors": ["denied"]})
server.expected_requests = 2
@ -856,7 +824,6 @@ async def test_public_vault_write_response_conversion_matches_python(
monkeypatch: pytest.MonkeyPatch,
body: bytes,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(body=body)
server.expected_requests = 2
@ -870,7 +837,6 @@ async def test_public_vault_write_response_conversion_matches_python(
async def test_public_vault_deletion_invalidates_cached_fields(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.enqueue(ResponseSpec(body=_vault_body("old")))
server.enqueue(ResponseSpec(status=204, body=b""))
@ -893,7 +859,6 @@ async def test_public_vault_rotation_preserves_response_and_best_effort_deletion
same_name: bool,
delete_status: int,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
response: Final = {"request_id": "write-id", "data": {"version": 3}, "extra": [1, 2]}
server.enqueue(ResponseSpec(body=b"current-existence-is-status-only"))
@ -924,7 +889,6 @@ async def test_public_vault_rotation_failure_messages_and_request_counts_match_p
stage: str,
status: int,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
before: Final = (
()
@ -960,7 +924,6 @@ async def test_public_vault_rotation_mismatches_do_not_delete_the_old_alias(
monkeypatch: pytest.MonkeyPatch,
value: JsonValue,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
responses: Final = (
ResponseSpec(body=_vault_body("old")),
@ -983,7 +946,6 @@ async def test_public_vault_mutation_timeouts_match_python(
monkeypatch: pytest.MonkeyPatch,
operation: str,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.default_response = ResponseSpec(body=_vault_body("value"), delay=0.25)
server.expected_requests = 2
@ -1021,7 +983,6 @@ async def test_public_vault_unsafe_names_fail_before_authentication(
monkeypatch: pytest.MonkeyPatch,
operation: str,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.expected_requests = 0
manager: Final = _vault(monkeypatch, server.base_url)
@ -1038,7 +999,6 @@ async def test_public_vault_authentication_errors_match_python(
monkeypatch: pytest.MonkeyPatch,
operation: str,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setenv("HCP_VAULT_APPROLE_ROLE_ID", "role")
monkeypatch.setenv("HCP_VAULT_APPROLE_SECRET_ID", "secret-id")
with recording_service() as server:
@ -1064,7 +1024,6 @@ async def test_public_vault_authentication_errors_match_python(
async def test_public_vault_rotation_stops_on_a_success_response_containing_an_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
result: Final = {"status": "error", "message": "write rejected", "extra": 2**100}
for response in (ResponseSpec(body=_vault_body("old")), ResponseSpec(body=result)) * 2:
@ -1078,7 +1037,6 @@ async def test_public_vault_rotation_stops_on_a_success_response_containing_an_e
async def test_public_native_vault_write_invalidates_stale_cached_values(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.enqueue(ResponseSpec(body=_vault_body("old")))
server.enqueue(ResponseSpec(body={"data": {"version": 2}}))
@ -1094,7 +1052,6 @@ async def test_public_native_vault_write_invalidates_stale_cached_values(monkeyp
async def test_public_native_vault_write_rejects_description_overwriting_the_secret(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
server.expected_requests = 0
handle: Final = _native_handle(_vault(monkeypatch, server.base_url))
@ -1111,7 +1068,6 @@ async def test_public_vault_rotation_preserves_malformed_verification_errors(
monkeypatch: pytest.MonkeyPatch,
body: JsonValue,
) -> None:
pytest.importorskip("litellm.rust_bridge._native")
with recording_service() as server:
responses: Final = (
ResponseSpec(body=_vault_body("old")),

View file

@ -17,8 +17,6 @@ from litellm.types.secret_managers.main import KeyManagementSettings, KeyManagem
from tests.test_litellm_rust.messages.data import MESSAGES, MESSAGES_MODEL, MESSAGES_RESPONSE
from tests.test_litellm_rust.support.recording_server import ResponseSpec, recording_service
pytest.importorskip("litellm.rust_bridge._native")
pytestmark = pytest.mark.usefixtures("local_model_cost_map")

View file

@ -12,6 +12,7 @@ import pytest
import litellm
from litellm.integrations.custom_secret_manager import CustomSecretManager
from litellm.llms.base_llm.ocr.transformation import OCRResponse
from litellm.rust_bridge import _native as native
from litellm.rust_bridge.host import settings
from litellm.rust_bridge.ocr.entrypoints import NATIVE_AOCR, NATIVE_OCR
from litellm.rust_bridge.public_call import NativeCall
@ -19,8 +20,6 @@ from litellm.types.secret_managers.main import KeyManagementSettings, KeyManagem
from tests.test_litellm_rust.ocr.data import OCR_DOCUMENT, OCR_MODEL, OCR_RESPONSE
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec, recording_service
native: Final = pytest.importorskip("litellm.rust_bridge._native")
AccessMode: TypeAlias = Literal["read_only", "write_only", "read_and_write"]

View file

@ -6,7 +6,7 @@ from typing import Final, cast # noqa: TID251 # narrows the async binding's un
import pytest
from litellm.rust_bridge import bindings
from litellm.rust_bridge import _native, bindings
from litellm.rust_bridge.chat_completions import entrypoints as chat_completions
from litellm.rust_bridge.embeddings import entrypoints as embeddings
from litellm.rust_bridge.execution import Complete, Step
@ -17,12 +17,15 @@ from litellm.rust_bridge.responses import entrypoints as responses
from litellm.rust_bridge.transcription import native as transcription
def test_binding_distinguishes_disable_from_reset(monkeypatch) -> None:
native = SimpleNamespace(route=lambda: "native")
monkeypatch.setattr(bindings, "get_native_bridge", lambda: native)
binding: bindings.NativeBinding[object] = bindings.NativeBinding("route", validate=lambda value: value)
def test_binding_distinguishes_disable_from_reset() -> None:
def route() -> str:
return "native"
assert binding.load() is native.route
binding: bindings.NativeBinding[object] = bindings.NativeBinding(
"route", validate=lambda value: value, source=SimpleNamespace(route=route)
)
assert binding.load() is route
binding.override(None)
assert binding.load() is None
@ -32,16 +35,15 @@ def test_binding_distinguishes_disable_from_reset(monkeypatch) -> None:
assert binding.load() is replacement
binding.reset()
assert binding.load() is native.route
assert binding.load() is route
@pytest.mark.parametrize(("value", "expected"), ((3, 3), ("invalid", None), (None, None)))
def test_binding_validates_native_attribute(
monkeypatch: pytest.MonkeyPatch, value: object, expected: int | None
) -> None:
def test_binding_validates_native_attribute(value: object, expected: int | None) -> None:
native: Final = SimpleNamespace(route=value)
monkeypatch.setattr(bindings, "get_native_bridge", lambda: native)
binding: Final = bindings.NativeBinding("route", validate=lambda item: item if isinstance(item, int) else None)
binding: Final = bindings.NativeBinding(
"route", validate=lambda item: item if isinstance(item, int) else None, source=native
)
assert binding.load() == expected
@ -72,28 +74,33 @@ ROUTE_BINDINGS: Final = tuple((attribute, binding) for attribute, binding, _ in
@pytest.mark.parametrize(
("attribute", "route_binding"), ROUTE_BINDINGS, ids=[attribute for attribute, _ in ROUTE_BINDINGS]
)
def test_route_bindings_reject_non_callable_native_attributes(
monkeypatch: pytest.MonkeyPatch, attribute: str, route_binding: bindings.NativeBinding[object]
def test_route_bindings_resolve_the_packaged_native_route(
attribute: str, route_binding: bindings.NativeBinding[object]
) -> None:
monkeypatch.setattr(bindings, "get_native_bridge", lambda: SimpleNamespace(**{attribute: "not callable"}))
route_binding.reset()
assert route_binding.load() is None
route_binding.reset()
assert attribute in dir(_native)
assert callable(route_binding.load())
@pytest.mark.parametrize(
("attribute", "route_binding"), ROUTE_BINDINGS, ids=[attribute for attribute, _ in ROUTE_BINDINGS]
)
def test_route_bindings_reject_non_callable_native_attributes(
attribute: str, route_binding: bindings.NativeBinding[object]
) -> None:
assert route_binding.with_source(SimpleNamespace(**{attribute: "not callable"})).load() is None
@pytest.mark.parametrize(
("attribute", "route_binding"), TRANSCRIPTION_BINDINGS, ids=[attribute for attribute, _ in TRANSCRIPTION_BINDINGS]
)
def test_transcription_bindings_expose_the_native_callable_itself(
monkeypatch: pytest.MonkeyPatch, attribute: str, route_binding: bindings.NativeBinding[object]
attribute: str, route_binding: bindings.NativeBinding[object]
) -> None:
def native_route() -> None:
pass
monkeypatch.setattr(bindings, "get_native_bridge", lambda: SimpleNamespace(**{attribute: native_route}))
route_binding.reset()
assert route_binding.load() is native_route
route_binding.reset()
assert route_binding.with_source(SimpleNamespace(**{attribute: native_route})).load() is native_route
@dataclass(frozen=True, slots=True)
@ -116,7 +123,6 @@ class _EchoingExecution:
ids=[attribute for attribute, _, _ in EXECUTION_BINDINGS],
)
def test_execution_bindings_start_the_native_execution_and_return_its_result(
monkeypatch: pytest.MonkeyPatch,
attribute: str,
route_binding: bindings.NativeBinding[object],
asynchronous: bool,
@ -128,9 +134,7 @@ def test_execution_bindings_start_the_native_execution_and_return_its_result(
received.append(incoming)
return _EchoingExecution()
monkeypatch.setattr(bindings, "get_native_bridge", lambda: SimpleNamespace(**{attribute: native_route}))
route_binding.reset()
bound: Final = route_binding.load()
bound: Final = route_binding.with_source(SimpleNamespace(**{attribute: native_route})).load()
assert callable(bound)
outcome: Final = bound(call)
@ -139,7 +143,6 @@ def test_execution_bindings_start_the_native_execution_and_return_its_result(
if asynchronous
else outcome
)
route_binding.reset()
assert received == [call]
assert result == ("completed with", None)

View file

@ -1,36 +1,21 @@
from types import SimpleNamespace
import pytest
from litellm.rust_bridge import fork_guard
def _reserve_with(monkeypatch: pytest.MonkeyPatch, native: object) -> None:
monkeypatch.setattr(fork_guard, "get_native_bridge", lambda: native)
fork_guard.reserve_process_for_forking("the gunicorn master")
def test_missing_extension_has_nothing_to_reserve(monkeypatch: pytest.MonkeyPatch) -> None:
assert _reserve_with(monkeypatch, None) is None
def test_extension_built_before_reservation_existed_passes(monkeypatch: pytest.MonkeyPatch) -> None:
assert _reserve_with(monkeypatch, SimpleNamespace()) is None
def test_unused_extension_is_reserved(monkeypatch: pytest.MonkeyPatch) -> None:
def test_unused_extension_is_reserved() -> None:
calls: list[None] = []
_reserve_with(monkeypatch, SimpleNamespace(reserve_process_for_forking=lambda: calls.append(None)))
fork_guard.reserve_process_for_forking("the gunicorn master", reserve=lambda: calls.append(None))
assert calls == [None]
def test_used_extension_refuses_and_names_the_place(monkeypatch: pytest.MonkeyPatch) -> None:
def test_used_extension_refuses_and_names_the_place() -> None:
def reserve() -> None:
raise RuntimeError("the native runtime already started in this process")
with pytest.raises(fork_guard.NativeStateStartedBeforeFork, match="the gunicorn master") as raised:
_reserve_with(monkeypatch, SimpleNamespace(reserve_process_for_forking=reserve))
fork_guard.reserve_process_for_forking("the gunicorn master", reserve=reserve)
assert isinstance(raised.value.__cause__, RuntimeError)

View file

@ -80,7 +80,6 @@ def test_native_logging_observes_level_changes(caplog: pytest.LogCaptureFixture)
),
)
def test_native_credential_patterns_match_python(text: str) -> None:
pytest.importorskip("litellm.rust_bridge._native")
from litellm.rust_bridge._native import NativeDiagnosticProcessor
processor: Final = NativeDiagnosticProcessor(MINIMUM_CUSTOM_KEY_LENGTH)
@ -91,7 +90,6 @@ def test_native_credential_patterns_match_python(text: str) -> None:
def test_native_client_redaction_matches_python() -> None:
pytest.importorskip("litellm.rust_bridge._native")
from litellm.rust_bridge._native import NativeDiagnosticProcessor
text: Final = "error at /etc/secrets/config on db.internal\nTraceback (most recent call last):\nsecret"
@ -100,7 +98,6 @@ def test_native_client_redaction_matches_python() -> None:
def test_native_diagnostic_batch_matches_python() -> None:
pytest.importorskip("litellm.rust_bridge._native")
from litellm.rust_bridge._native import NativeDiagnosticProcessor
message: Final = "é" * 110 + "sk-" + "q" * 48 + "界" * 1000

View file

@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import Callable, Generator
from types import SimpleNamespace
from typing import Final, Protocol
import pytest
@ -10,26 +9,14 @@ from litellm.exceptions import APIError
from litellm.llms.base_llm.ocr.transformation import OCRResponse
from litellm.router_utils.add_retry_fallback_headers import get_hidden_params_dict
from litellm.rust_bridge import bindings, configuration, runtime
from litellm.rust_bridge._native import RustBridgeDeclined, RustUpstreamError
from litellm.rust_bridge.catalog import Route, RouteContext, RouteRule
from litellm.rust_bridge.configuration import Rollout
from litellm.rust_bridge.execution import AsyncStream, Complete, StreamChunk, SyncStream
class RustBridgeDeclined(Exception):
pass
class RustUpstreamError(Exception):
pass
@pytest.fixture(autouse=True)
def native_exceptions(monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
native: Final = SimpleNamespace(
RustBridgeDeclined=RustBridgeDeclined,
RustUpstreamError=RustUpstreamError,
)
monkeypatch.setattr(bindings, "get_native_bridge", lambda: native)
def rust_configuration(monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
monkeypatch.delenv("LITELLM_RUST", raising=False)
configuration.reset_rust_configuration()
yield

View file

@ -16,6 +16,7 @@ import litellm
from litellm.constants import TIKTOKEN_ENCODE_CHUNK_SIZE_CHARS
from litellm.litellm_core_utils.token_counter import openai_tokenizer_encoding
from litellm.proxy.spend_tracking.input_tokens import count_input_tokens_for_model
from litellm.rust_bridge import _native as native
from litellm.rust_bridge import token_counter as bridge
from litellm.rust_bridge import tokenizer as tokenizer_dispatch
from litellm.rust_bridge._native import Tokenizer
@ -313,7 +314,6 @@ PARITY_MODELS: Final[tuple[tuple[str, bridge.RustTokenizer], ...]] = (
async def test_native_count_matches_python_budget_counter(
request_body: dict[str, object], model: str, tokenizer: bridge.RustTokenizer
) -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
body: Final = json.dumps(request_body).replace(MODEL, model)
parsed: Final = json.loads(body)
@ -328,7 +328,6 @@ async def test_tiktoken_counts_long_text_exactly_where_python_chunks(
model: str, tokenizer: bridge.RustTokenizer
) -> None:
"""Python encodes tiktoken text in fixed-size chunks (drift of up to one token per chunk boundary); Rust does not."""
native: Final = pytest.importorskip("litellm.rust_bridge._native")
text: Final = "x " * 20_000
body: Final = {"model": model, "messages": [{"role": "user", "content": text}]}
encoding: Final = Tokenizer.from_tiktoken(tokenizer)
@ -362,7 +361,6 @@ DECLINED_REQUESTS: Final[tuple[dict[str, object], ...]] = (
async def test_native_declines_shapes_python_prices_differently(
request_body: dict[str, object], tokenizer: bridge.RustTokenizer
) -> None:
native: Final = pytest.importorskip("litellm.rust_bridge._native")
raw, _ = _counted(request_body, MODEL_BY_TOKENIZER[tokenizer])
with pytest.raises(native.RustBridgeDeclined):

View file

@ -1,14 +1,14 @@
from __future__ import annotations
from collections.abc import Generator
from types import SimpleNamespace
from typing import Final
import pytest
import litellm
from litellm.llms.bedrock.audio_transcription import BedrockAudioTranscriptionRustDispatch
from litellm.rust_bridge import bindings, configuration
from litellm.rust_bridge import configuration
from litellm.rust_bridge._native import RustBridgeDeclined, RustUpstreamError
from litellm.rust_bridge.public_call import NativeCall
from litellm.rust_bridge.transcription.native import NATIVE_ATRANSCRIPTION, NATIVE_TRANSCRIPTION
@ -28,18 +28,8 @@ TRANSCRIPTION_FIELDS: Final = frozenset(
)
class RustBridgeDeclined(Exception):
pass
class RustUpstreamError(Exception):
pass
@pytest.fixture(autouse=True)
def isolated_bridge(monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
native: Final = SimpleNamespace(RustBridgeDeclined=RustBridgeDeclined, RustUpstreamError=RustUpstreamError)
monkeypatch.setattr(bindings, "get_native_bridge", lambda: native)
monkeypatch.delenv("LITELLM_RUST", raising=False)
configuration.reset_rust_configuration()
yield

View file

@ -862,8 +862,6 @@ def test_secret_filter_keeps_truncated_traceback(monkeypatch):
@pytest.mark.parametrize("native", (False, True), ids=("python", "rust"))
def test_diagnostic_redaction_precedes_a_credential_cut(monkeypatch, native):
if native:
pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setenv("LITELLM_RUST", "1" if native else "0")
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "500")
monkeypatch.setenv("MAX_BASE64_LENGTH_STDOUT_LOG", "0")
@ -890,8 +888,6 @@ def test_correlation_id_redacts_before_its_length_bound(monkeypatch):
@pytest.mark.parametrize("native", (False, True), ids=("python", "rust"))
def test_malformed_interpolation_still_scrubs_a_record(monkeypatch, native):
if native:
pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setenv("LITELLM_RUST", "1" if native else "0")
monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True)
record = _make_record(logging.WARNING, "bad % api_key=secret123", ("value",))
@ -905,8 +901,6 @@ def test_malformed_interpolation_still_scrubs_a_record(monkeypatch, native):
@pytest.mark.parametrize("native", (False, True), ids=("python", "rust"))
def test_key_pattern_template_keeps_the_rendered_redacted_line(monkeypatch, native):
if native:
pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setenv("LITELLM_RUST", "1" if native else "0")
monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True)
record = _make_record(logging.INFO, "password=%s ok", ("hunter2",))
@ -1733,8 +1727,6 @@ def test_access_log_path_filter_keeps_a_record_without_a_string_path_arg(monkeyp
@pytest.mark.parametrize("native", (False, True), ids=("python", "rust"))
def test_diagnostic_filter_scrubs_exc_stack_and_nested_extras(monkeypatch, native):
if native:
pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setenv("LITELLM_RUST", "1" if native else "0")
monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True)
secret = "sk-" + "q" * 48
@ -1761,8 +1753,6 @@ def test_diagnostic_filter_scrubs_exc_stack_and_nested_extras(monkeypatch, nativ
@pytest.mark.parametrize("native", (False, True), ids=("python", "rust"))
def test_diagnostic_filter_stamps_records_so_a_second_pass_is_free(monkeypatch, native):
if native:
pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setenv("LITELLM_RUST", "1" if native else "0")
monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True)
record = _make_record(logging.WARNING, "api_key=secret123")
@ -1775,8 +1765,6 @@ def test_diagnostic_filter_stamps_records_so_a_second_pass_is_free(monkeypatch,
@pytest.mark.parametrize("native", (False, True), ids=("python", "rust"))
def test_json_formatter_scrubs_unfiltered_extras(monkeypatch, native):
if native:
pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setenv("LITELLM_RUST", "1" if native else "0")
monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True)
secret = "sk-" + "q" * 48
@ -1791,8 +1779,6 @@ def test_json_formatter_scrubs_unfiltered_extras(monkeypatch, native):
@pytest.mark.parametrize("native", (False, True), ids=("python", "rust"))
def test_diagnostic_filter_redacts_a_non_string_message_object(monkeypatch, native):
if native:
pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setenv("LITELLM_RUST", "1" if native else "0")
monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True)
secret = "sk-" + "q" * 48

View file

@ -2127,6 +2127,330 @@ def test_responses_bridge_preserves_reasoning_effort_with_drop_params(
assert request_body["reasoning"] == {"effort": "high"}
@pytest.fixture
def openai_responses_route(
restore_model_registry, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> respx.Route:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.delenv("LITELLM_REASONING_AUTO_SUMMARY", raising=False)
litellm.register_model(
{
"gpt-6.1-terra": {
"litellm_provider": "openai",
"mode": "chat",
"supports_reasoning": True,
"supports_minimal_reasoning_effort": False,
"reasoning_modes": ["standard", "pro"],
"reasoning_contexts": ["auto", "current_turn", "all_turns"],
"input_cost_per_token": 0.0,
"output_cost_per_token": 0.0,
}
},
persist_across_reloads=False,
)
return respx_mock.post("https://api.openai.com/v1/responses").respond(
json={
"id": "resp_test",
"object": "response",
"created_at": 1734366691,
"status": "completed",
"model": "gpt-6.1-terra",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "Done.", "annotations": []}],
}
],
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
}
)
@pytest.mark.parametrize(
("reasoning_effort", "summary_alias", "expected_reasoning"),
[
(
{"effort": "low", "mode": "pro", "context": "current_turn"},
{},
{"effort": "low", "mode": "pro", "context": "current_turn"},
),
(
{"effort": "low", "mode": "pro", "context": "current_turn"},
{"reasoning_summary": "auto"},
{"effort": "low", "mode": "pro", "context": "current_turn", "summary": "auto"},
),
(
{"effort": "low", "mode": "pro", "summary": "concise"},
{"reasoningSummary": "auto"},
{"effort": "low", "mode": "pro", "summary": "concise"},
),
(
{"effort": "low", "context": "current_turn", "summary": None},
{"reasoning_summary": "auto"},
{"effort": "low", "context": "current_turn", "summary": None},
),
({"mode": "pro", "context": "all_turns"}, {}, {"mode": "pro", "context": "all_turns"}),
("low", {}, {"effort": "low"}),
],
)
def test_openai_responses_bridge_sends_the_whole_reasoning_effort_dict(
reasoning_effort: str | dict[str, str | None],
summary_alias: dict[str, str],
expected_reasoning: dict[str, str | None],
openai_responses_route: respx.Route,
):
caller_reasoning_effort: Final = copy.deepcopy(reasoning_effort)
litellm.completion(
model="openai/responses/gpt-6.1-terra",
messages=[{"role": "user", "content": "hello"}],
reasoning_effort=reasoning_effort,
api_key="fake-key",
**summary_alias,
)
request_body: Final = json.loads(openai_responses_route.calls[0].request.content)
assert request_body["reasoning"] == expected_reasoning
assert reasoning_effort == caller_reasoning_effort
@pytest.mark.parametrize(
"reasoning_effort, summary_alias, expected_reasoning",
[
(
{"effort": "minimal", "mode": "pro", "context": "current_turn"},
{},
{"mode": "pro", "context": "current_turn"},
),
({"effort": "minimal", "mode": "pro", "summary": "auto"}, {}, {"mode": "pro", "summary": "auto"}),
({"effort": "minimal", "mode": "pro"}, {"reasoning_summary": "auto"}, {"mode": "pro", "summary": "auto"}),
],
)
def test_openai_responses_bridge_drops_only_a_refused_effort_under_drop_params(
reasoning_effort: dict[str, str],
summary_alias: dict[str, str],
expected_reasoning: dict[str, str],
openai_responses_route: respx.Route,
):
litellm.completion(
model="openai/responses/gpt-6.1-terra",
messages=[{"role": "user", "content": "hello"}],
reasoning_effort=reasoning_effort,
drop_params=True,
api_key="fake-key",
**summary_alias,
)
request_body: Final = json.loads(openai_responses_route.calls[0].request.content)
assert request_body["reasoning"] == expected_reasoning
def test_openai_responses_bridge_drops_a_reasoning_effort_dict_for_a_model_without_reasoning(
openai_responses_route: respx.Route,
):
litellm.completion(
model="openai/responses/gpt-4.1",
messages=[{"role": "user", "content": "hello"}],
reasoning_effort={"effort": "low", "mode": "pro", "context": "current_turn"},
drop_params=True,
api_key="fake-key",
)
request_body: Final = json.loads(openai_responses_route.calls[0].request.content)
assert "reasoning" not in request_body
@pytest.mark.parametrize(
"drop_params, expected_reasoning",
[
(True, {"effort": "low", "context": "current_turn"}),
(
False,
{
"effort": "low",
"exclude": False,
"mode": "turbo",
"context": "current_turn",
"generate_summary": "concise",
},
),
],
)
def test_openai_responses_bridge_drops_reasoning_fields_openai_refuses_only_under_drop_params(
drop_params: bool,
expected_reasoning: dict[str, object],
openai_responses_route: respx.Route,
):
litellm.completion(
model="openai/responses/gpt-6.1-terra",
messages=[{"role": "user", "content": "hello"}],
reasoning_effort={
"effort": "low",
"exclude": False,
"mode": "turbo",
"context": "current_turn",
"generate_summary": "concise",
},
drop_params=drop_params,
api_key="fake-key",
)
request_body: Final = json.loads(openai_responses_route.calls[0].request.content)
assert request_body["reasoning"] == expected_reasoning
@pytest.mark.parametrize(
"declared, drop_params, expected_reasoning",
[
(
{"reasoning_modes": ["standard"], "reasoning_contexts": ["auto", "current_turn"]},
True,
{"effort": "low"},
),
(
{"reasoning_modes": ["pro"], "reasoning_contexts": ["all_turns"]},
True,
{"effort": "low", "mode": "pro", "context": "all_turns"},
),
({}, True, {"effort": "low", "mode": "pro", "context": "all_turns"}),
({"reasoning_modes": ["standard"]}, True, {"effort": "low", "context": "all_turns"}),
(
{"reasoning_modes": ["standard"], "reasoning_contexts": ["auto", "current_turn"]},
False,
{"effort": "low", "mode": "pro", "context": "all_turns"},
),
],
)
def test_openai_responses_bridge_drops_a_mode_or_context_the_model_does_not_list_only_under_drop_params(
declared: dict[str, list[str]],
drop_params: bool,
expected_reasoning: dict[str, str],
openai_responses_route: respx.Route,
):
litellm.register_model(
{
"gpt-6.1-reasoning-test": {
"litellm_provider": "openai",
"mode": "chat",
"supports_reasoning": True,
"input_cost_per_token": 0.0,
"output_cost_per_token": 0.0,
**declared,
}
},
persist_across_reloads=False,
)
litellm.completion(
model="openai/responses/gpt-6.1-reasoning-test",
messages=[{"role": "user", "content": "hello"}],
reasoning_effort={"effort": "low", "mode": "pro", "context": "all_turns"},
drop_params=drop_params,
api_key="fake-key",
)
request_body: Final = json.loads(openai_responses_route.calls[0].request.content)
assert request_body["reasoning"] == expected_reasoning
def test_openai_responses_bridge_forwards_mode_and_context_under_drop_params_for_a_catalog_model_with_no_cost_entry(
openai_responses_route: respx.Route,
):
litellm.open_ai_chat_completion_models.add("unpriced-reasoning-model")
litellm.completion(
model="openai/responses/unpriced-reasoning-model",
messages=[{"role": "user", "content": "hello"}],
reasoning_effort={"effort": "low", "mode": "pro", "context": "all_turns", "generate_summary": "auto"},
drop_params=True,
api_key="fake-key",
)
request_body: Final = json.loads(openai_responses_route.calls[0].request.content)
assert request_body["reasoning"] == {"effort": "low", "mode": "pro", "context": "all_turns"}
def test_openai_responses_bridge_forwards_a_reasoning_effort_dict_whole_for_a_model_outside_the_openai_catalog(
openai_responses_route: respx.Route,
):
reasoning_effort: Final = {"effort": "high", "exclude": True, "budget_tokens": 2000}
litellm.completion(
model="openai/responses/my-proxy-alias",
messages=[{"role": "user", "content": "hello"}],
reasoning_effort=reasoning_effort,
drop_params=True,
api_key="fake-key",
)
request_body: Final = json.loads(openai_responses_route.calls[0].request.content)
assert request_body["reasoning"] == reasoning_effort
@pytest.mark.parametrize(
"additional_drop_params, summary_alias, expected_reasoning",
[
(["reasoning_effort.mode"], {}, {"effort": "low", "context": "current_turn"}),
(
["reasoning_effort.summary"],
{"reasoning_summary": "auto"},
{"effort": "low", "mode": "pro", "context": "current_turn"},
),
(["reasoning_effort"], {"reasoning_summary": "auto"}, None),
("reasoning_effort", {}, {"effort": "low", "mode": "pro", "context": "current_turn"}),
],
)
def test_openai_responses_bridge_applies_additional_drop_params_to_a_reasoning_effort_dict(
additional_drop_params: list[str] | str,
summary_alias: dict[str, str],
expected_reasoning: dict[str, str] | None,
openai_responses_route: respx.Route,
):
litellm.completion(
model="openai/responses/gpt-6.1-terra",
messages=[{"role": "user", "content": "hello"}],
reasoning_effort={"effort": "low", "mode": "pro", "context": "current_turn"},
additional_drop_params=additional_drop_params,
api_key="fake-key",
**summary_alias,
)
request_body: Final = json.loads(openai_responses_route.calls[0].request.content)
assert request_body.get("reasoning") == expected_reasoning
def test_openai_responses_bridge_gives_a_reasoning_effort_dict_the_auto_summary_a_string_gets(
openai_responses_route: respx.Route, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true")
litellm.completion(
model="openai/responses/gpt-6.1-terra",
messages=[{"role": "user", "content": "hello"}],
reasoning_effort="low",
api_key="fake-key",
)
litellm.completion(
model="openai/responses/gpt-6.1-terra",
messages=[{"role": "user", "content": "hello"}],
reasoning_effort={"effort": "low", "mode": "pro", "context": "current_turn"},
api_key="fake-key",
)
string_reasoning, dict_reasoning = (
json.loads(call.request.content)["reasoning"] for call in openai_responses_route.calls
)
assert string_reasoning["summary"] is not None
assert dict_reasoning == {
"effort": "low",
"mode": "pro",
"context": "current_turn",
"summary": string_reasoning["summary"],
}
_BRIDGED_RESPONSE_ANSWER: Final = json.dumps(
{
"id": "resp_slow",

View file

@ -115,6 +115,23 @@ def test_github_copilot_backup_rows_match_the_main_catalog() -> None:
assert _github_copilot_catalog_rows(PRICES_PATH) == _github_copilot_catalog_rows(BACKUP_PRICES_PATH)
class _TokenLimits(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
max_input_tokens: int
max_output_tokens: int
@pytest.mark.parametrize("path", (PRICES_PATH, BACKUP_PRICES_PATH), ids=("main", "backup"))
def test_zerogpu_rows_have_an_output_limit_that_fits_the_context_window(path: Path) -> None:
prices: Final[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue]).validate_json(path.read_bytes())
rows: Final = MappingProxyType(
{key: _TokenLimits.model_validate(value) for key, value in prices.items() if key.startswith("zerogpu/")}
)
assert rows, f"No ZeroGPU rows found in {path}"
assert tuple(key for key, row in rows.items() if row.max_output_tokens > row.max_input_tokens) == ()
def test_github_copilot_rows_resolve_through_get_model_info() -> None:
rows: Final[Mapping[str, _CopilotEndpointRow]] = _copilot_endpoint_rows(PRICES_PATH)
assert {

View file

@ -20630,6 +20630,37 @@ def test_resolved_litellm_models_answers_through_every_channel_a_request_uses(
assert set(_resolution_router().resolved_litellm_models(model_name)) == set(expected)
def test_resolved_litellm_models_base_model_precedence_is_opt_in() -> None:
router: Final = Router(
model_list=[
{
"model_name": "pooled",
"litellm_params": {"model": "openai/deployment-a", "api_key": "sk-test", "base_model": "gpt-5.5"},
"model_info": {"base_model": "gpt-6-astra"},
},
{
"model_name": "pooled",
"litellm_params": {"model": "openai/deployment-b", "api_key": "sk-test", "base_model": "gpt-5.6-sol"},
},
{
"model_name": "team-deployment",
"litellm_params": {"model": "openai/deployment-c", "api_key": "sk-test"},
"model_info": {
"base_model": "gpt-6-astra",
"team_id": "test-team",
"team_public_model_name": "team-model",
},
},
],
model_group_alias={"nickname": "pooled"},
)
assert router.resolved_litellm_models("nickname") == ("openai/deployment-a", "openai/deployment-b")
assert router.resolved_litellm_models("nickname", use_base_model=True) == ("gpt-6-astra", "gpt-5.6-sol")
assert router.resolved_litellm_models("team-model", "test-team") == ("openai/deployment-c",)
assert router.resolved_litellm_models("team-model", "test-team", use_base_model=True) == ("gpt-6-astra",)
assert router.resolved_litellm_models("never-configured", use_base_model=True) == ()
class TestTierParamsTheTargetAccepts:
"""A tier's litellm_params are applied to every request that tier routes, so one the target
cannot take raised UnsupportedParamsError before the request left the proxy, turning the whole

View file

@ -76,8 +76,6 @@ def test_redact_string_catches_secret_patterns():
@pytest.mark.parametrize("native", (False, True), ids=("python", "rust"))
def test_diagnostic_redaction_policy_matches_across_backends(monkeypatch: pytest.MonkeyPatch, native: bool) -> None:
if native:
pytest.importorskip("litellm.rust_bridge._native")
monkeypatch.setenv("LITELLM_RUST", "1" if native else "0")
assert redact_string("GET /v1?api_key=abcdefgh12345&page=2") == "GET /v1?REDACTED&page=2"

View file

@ -1195,6 +1195,8 @@ _COST_MAP_ROW_SCHEMA: Final = {
"type": "array",
"items": {"type": "string", "enum": ["none", "minimal", "low", "medium", "high", "xhigh", "max"]},
},
"reasoning_modes": {"type": "array", "items": {"type": "string"}},
"reasoning_contexts": {"type": "array", "items": {"type": "string"}},
"default_reasoning_effort": {
"type": "string",
"enum": ["none", "minimal", "low", "medium", "high", "xhigh"],

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