mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_fix_unscoped_managed_files
This commit is contained in:
commit
2adccb3b83
159 changed files with 6321 additions and 4534 deletions
31
.github/actions/cache-cargo-build/action.yml
vendored
Normal file
31
.github/actions/cache-cargo-build/action.yml
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
name: "Cache the Rust build"
|
||||
description: >-
|
||||
Cache the Cargo registry and target directory the root package's build needs,
|
||||
so only the first job on a given Cargo.lock compiles the bridge from scratch.
|
||||
|
||||
litellm builds through maturin, which compiles litellm-rust/crates/python-bridge
|
||||
in release mode before it can produce a wheel. `uv sync` therefore pays a full
|
||||
build in every job that installs the workspace: measured at 2m40s per unit shard
|
||||
on 2026-08-21, more than the whole unit tier spends running tests. Nothing caught
|
||||
it, because the uv cache holds wheels uv downloads rather than wheels it builds,
|
||||
and a path dependency whose source moves every commit could never hit that cache
|
||||
anyway. Cargo rebuilds only what changed when its target directory survives, so a
|
||||
warm job pays for the bridge crate alone.
|
||||
|
||||
The key namespace is separate from test-rust.yml's. Both cache the same directory,
|
||||
but that workflow fills it with debug and clippy artifacts, which a release build
|
||||
cannot reuse, and a shared key would let whichever ran first deny the other a save.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore the Cargo registry and target directory
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
litellm-rust/target
|
||||
key: ${{ runner.os }}-cargo-release-${{ hashFiles('litellm-rust/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-release-
|
||||
10
.github/ci-coverage-allowlist.yml
vendored
10
.github/ci-coverage-allowlist.yml
vendored
|
|
@ -48,16 +48,6 @@ test_paths:
|
|||
choice it informed is settled
|
||||
paths:
|
||||
- tests/code_coverage_tests/test_aio_http_image_conversion.py
|
||||
- reason: >-
|
||||
The last file of a second mirror that sat beside tests/test_litellm and ran nowhere. Its
|
||||
other 33 files landed in the real mirror during August 2026, 30 as moves and 3 by merging
|
||||
their bodies into the live file of the same name. This one cannot follow either route yet:
|
||||
its live twin was rewritten from 1268 lines to 9434, and of the 19 tests here 5 have no
|
||||
counterpart while 25 assertions fail against today's code, so what survives that rewrite
|
||||
is a judgement about the endpoints, not a merge. Revisit by deciding which of the five
|
||||
behaviours still hold
|
||||
paths:
|
||||
- tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py
|
||||
- reason: >-
|
||||
No job invokes this suite and its files mix pure transformation tests with ones driving live
|
||||
vendor vector stores, so assigning them needs a per-file decision
|
||||
|
|
|
|||
9
.github/workflows/_test-unit-base.yml
vendored
9
.github/workflows/_test-unit-base.yml
vendored
|
|
@ -27,7 +27,7 @@ on:
|
|||
default: 20
|
||||
job-timeout-minutes:
|
||||
description: >-
|
||||
Backstop for the whole job. Keep it >= `timeout-minutes` plus 35: 30 for
|
||||
Backstop for the whole job. Keep it >= `timeout-minutes` plus 40: 35 for
|
||||
the per-step ceilings on the setup steps below, and 5 for the runner
|
||||
overhead the job clock charges but no step owns (job init, step
|
||||
transitions, post-job cleanup). That headroom is what makes the test
|
||||
|
|
@ -36,7 +36,7 @@ on:
|
|||
arithmetic, so the sum is passed in rather than computed.
|
||||
required: false
|
||||
type: number
|
||||
default: 55
|
||||
default: 60
|
||||
max-failures:
|
||||
description: "Stop after this many failures"
|
||||
required: false
|
||||
|
|
@ -103,6 +103,11 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-uv-
|
||||
|
||||
- name: Cache the Rust build
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 5
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 8
|
||||
|
|
|
|||
4
.github/workflows/check-ui-api-types.yml
vendored
4
.github/workflows/check-ui-api-types.yml
vendored
|
|
@ -67,6 +67,10 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-uv-
|
||||
|
||||
- name: Cache the Rust build
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install backend dependencies
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
run: .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
|
||||
|
|
|
|||
3
.github/workflows/mutation-test.yml
vendored
3
.github/workflows/mutation-test.yml
vendored
|
|
@ -53,6 +53,9 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-uv-
|
||||
|
||||
- name: Cache the Rust build
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ jobs:
|
|||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache the Rust build
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Cache Prisma binaries
|
||||
uses: ./.github/actions/cache-prisma-binaries
|
||||
|
||||
|
|
|
|||
3
.github/workflows/test-code-quality.yml
vendored
3
.github/workflows/test-code-quality.yml
vendored
|
|
@ -56,6 +56,9 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-uv-
|
||||
|
||||
- name: Cache the Rust build
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --all-groups --all-extras
|
||||
|
||||
|
|
|
|||
4
.github/workflows/test-linting.yml
vendored
4
.github/workflows/test-linting.yml
vendored
|
|
@ -78,6 +78,10 @@ jobs:
|
|||
run: |
|
||||
uv lock --check || (echo "❌ uv.lock is out of sync with pyproject.toml. Run 'uv lock' locally and commit the result." && exit 1)
|
||||
|
||||
- name: Cache the Rust build
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: |
|
||||
|
|
|
|||
4
.github/workflows/test-mcp.yml
vendored
4
.github/workflows/test-mcp.yml
vendored
|
|
@ -47,6 +47,10 @@ jobs:
|
|||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache the Rust build
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-uv-
|
||||
|
||||
- name: Cache the Rust build
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-uv-
|
||||
|
||||
- name: Cache the Rust build
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: |
|
||||
|
|
|
|||
22
.github/workflows/test-unit.yml
vendored
22
.github/workflows/test-unit.yml
vendored
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 1
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: enterprise-routing
|
||||
artifact-name: enterprise-routing
|
||||
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: integrations
|
||||
artifact-name: integrations
|
||||
|
|
@ -75,7 +75,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 3
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: Vertex AI
|
||||
artifact-name: llm-vertex-ai
|
||||
|
|
@ -83,7 +83,7 @@ jobs:
|
|||
workers: 1
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: All Other Providers
|
||||
artifact-name: llm-other-providers
|
||||
|
|
@ -91,7 +91,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: misc
|
||||
artifact-name: misc
|
||||
|
|
@ -122,7 +122,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: proxy-auth
|
||||
artifact-name: proxy-auth
|
||||
|
|
@ -134,7 +134,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: proxy-endpoints
|
||||
artifact-name: proxy-endpoints
|
||||
|
|
@ -171,7 +171,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: proxy-server
|
||||
artifact-name: proxy-server
|
||||
|
|
@ -179,7 +179,7 @@ jobs:
|
|||
workers: 4
|
||||
reruns: 2
|
||||
timeout-minutes: 60
|
||||
job-timeout-minutes: 95
|
||||
job-timeout-minutes: 100
|
||||
|
||||
- shard: proxy-infra
|
||||
artifact-name: proxy-infra
|
||||
|
|
@ -198,7 +198,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: responses-caching-types
|
||||
artifact-name: responses-caching-types
|
||||
|
|
@ -209,7 +209,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
uses: ./.github/workflows/_test-unit-base.yml
|
||||
with:
|
||||
test-path: ${{ matrix.test-path }}
|
||||
|
|
|
|||
3
.github/workflows/weekly_load_anomaly.yml
vendored
3
.github/workflows/weekly_load_anomaly.yml
vendored
|
|
@ -47,6 +47,9 @@ jobs:
|
|||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache the Rust build
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra proxy
|
||||
|
|
|
|||
|
|
@ -296,6 +296,32 @@ def calculate_vertex_ai_batch_cost_and_usage(
|
|||
)
|
||||
|
||||
|
||||
def _provider_output_file_id(output_file_id: str) -> str:
|
||||
"""
|
||||
Resolve the file id the provider actually knows: unified ids yield their embedded
|
||||
llm_output_file_id, model-encoded ids decode to the raw provider id, raw ids pass through.
|
||||
"""
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
_is_base64_encoded_unified_file_id,
|
||||
get_original_file_id,
|
||||
)
|
||||
|
||||
unified_file_id: Final = _is_base64_encoded_unified_file_id(output_file_id)
|
||||
if not unified_file_id:
|
||||
return get_original_file_id(output_file_id)
|
||||
try:
|
||||
extracted: Final = unified_file_id.split("llm_output_file_id,")[1].split(";")[0]
|
||||
except (IndexError, AttributeError) as e:
|
||||
verbose_logger.error(
|
||||
"Failed to extract LLM output file ID from unified file ID: %s, error: %s",
|
||||
output_file_id,
|
||||
e,
|
||||
)
|
||||
return output_file_id
|
||||
verbose_logger.debug("Extracted LLM output file ID from unified file ID: %s", extracted)
|
||||
return extracted
|
||||
|
||||
|
||||
async def _fetch_batch_output_file_content(
|
||||
batch: Batch,
|
||||
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"] = "openai",
|
||||
|
|
@ -311,23 +337,11 @@ async def _fetch_batch_output_file_content(
|
|||
Required for Azure and other providers that need authentication
|
||||
"""
|
||||
from litellm.files.main import afile_content
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
_is_base64_encoded_unified_file_id,
|
||||
)
|
||||
|
||||
if batch.output_file_id is None:
|
||||
raise ValueError("Output file id is None cannot retrieve file content")
|
||||
|
||||
file_id = batch.output_file_id
|
||||
is_base64_unified_file_id: Final = _is_base64_encoded_unified_file_id(file_id)
|
||||
if is_base64_unified_file_id:
|
||||
try:
|
||||
file_id = is_base64_unified_file_id.split("llm_output_file_id,")[1].split(";")[0]
|
||||
verbose_logger.debug("Extracted LLM output file ID from unified file ID: %s", file_id)
|
||||
except (IndexError, AttributeError) as e:
|
||||
verbose_logger.error(
|
||||
"Failed to extract LLM output file ID from unified file ID: %s, error: %s", batch.output_file_id, e
|
||||
)
|
||||
file_id: Final = _provider_output_file_id(batch.output_file_id)
|
||||
|
||||
# Build kwargs for afile_content with credentials from litellm_params
|
||||
file_content_kwargs: Final = {
|
||||
|
|
|
|||
|
|
@ -124,6 +124,14 @@ def ptu_identity_error(
|
|||
return None
|
||||
|
||||
|
||||
PTU_MODEL_INFO_FIELDS: Final = ("ptu_count", "cost_per_ptu_per_hour", "ptu_effective_from", "ptu_effective_to")
|
||||
|
||||
|
||||
def declares_ptu(model_info: Mapping[str, object]) -> bool:
|
||||
"""Whether any PTU field is set here, including one too malformed to charge."""
|
||||
return any(model_info.get(field) is not None for field in PTU_MODEL_INFO_FIELDS)
|
||||
|
||||
|
||||
def ptu_config_error(model_info: Mapping[str, object], *, model_name: str | None = None) -> str | None:
|
||||
"""Why this PTU configuration cannot be honoured, else None.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from litellm.llms.anthropic.experimental_pass_through.context_management import
|
|||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.utils import (
|
||||
is_reasoning_auto_summary_enabled,
|
||||
local_model_name,
|
||||
)
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import (
|
||||
AnthropicMessagesResponse,
|
||||
|
|
@ -358,9 +359,9 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if isinstance(model, str) and model and not model.startswith("responses/"):
|
||||
# Prefix model with "responses/" to route to OpenAI Responses API
|
||||
completion_kwargs["model"] = f"responses/{model}"
|
||||
if isinstance(model, str) and model and "responses/" not in model:
|
||||
local_model: Final = model.removeprefix(f"{custom_llm_provider}/")
|
||||
completion_kwargs["model"] = f"{custom_llm_provider}/responses/{local_model}"
|
||||
|
||||
auto_summary: Final = is_reasoning_auto_summary_enabled()
|
||||
|
||||
|
|
@ -616,7 +617,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
if stream:
|
||||
transformed_stream: Final = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming(
|
||||
completion_response,
|
||||
model=model,
|
||||
model=local_model_name(model, kwargs.get("custom_llm_provider")),
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
is_async=True,
|
||||
|
|
@ -750,7 +751,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
if stream:
|
||||
transformed_stream: Final = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming(
|
||||
completion_response,
|
||||
model=model,
|
||||
model=local_model_name(model, kwargs.get("custom_llm_provider")),
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
is_async=False,
|
||||
|
|
|
|||
|
|
@ -42,15 +42,46 @@ from .utils import AnthropicMessagesRequestUtils, mock_response
|
|||
_RESPONSES_API_PROVIDERS: Final = frozenset({"openai"})
|
||||
|
||||
|
||||
def _should_route_to_responses_api(custom_llm_provider: str | None) -> bool:
|
||||
"""Return True when the provider should use the Responses API path.
|
||||
def _bridges_to_responses_api(model: str, custom_llm_provider: str) -> bool:
|
||||
from litellm.main import responses_api_bridge_check
|
||||
|
||||
model_info, _ = responses_api_bridge_check(model=model, custom_llm_provider=custom_llm_provider)
|
||||
return model_info.get("mode") == "responses"
|
||||
|
||||
|
||||
def _responses_mode_is_lost_by_prefix_strip(
|
||||
requested_model: str, resolved_model: str, custom_llm_provider: str
|
||||
) -> bool:
|
||||
"""Whether a Responses-only deployment stops looking like one once its provider prefix is stripped.
|
||||
|
||||
``litellm.completion`` re-derives the Responses bridge from the stripped id alone, so a
|
||||
deployment id such as ``perplexity/perplexity/sonar`` (mode ``responses``) is shadowed by the
|
||||
chat entry ``perplexity/sonar`` and would otherwise be sent to chat/completions.
|
||||
"""
|
||||
if requested_model == resolved_model:
|
||||
return False
|
||||
return _bridges_to_responses_api(requested_model, custom_llm_provider) and not _bridges_to_responses_api(
|
||||
resolved_model, custom_llm_provider
|
||||
)
|
||||
|
||||
|
||||
def _should_route_to_responses_api(
|
||||
custom_llm_provider: str | None,
|
||||
requested_model: str | None = None,
|
||||
resolved_model: str | None = None,
|
||||
) -> bool:
|
||||
"""Return True when the request should use the Responses API path.
|
||||
|
||||
Set ``litellm.use_chat_completions_url_for_anthropic_messages = True`` to
|
||||
opt out and route OpenAI/Azure requests through chat/completions instead.
|
||||
"""
|
||||
if litellm.use_chat_completions_url_for_anthropic_messages:
|
||||
return False
|
||||
return custom_llm_provider in _RESPONSES_API_PROVIDERS
|
||||
if custom_llm_provider in _RESPONSES_API_PROVIDERS:
|
||||
return True
|
||||
if custom_llm_provider is None or requested_model is None or resolved_model is None:
|
||||
return False
|
||||
return _responses_mode_is_lost_by_prefix_strip(requested_model, resolved_model, custom_llm_provider)
|
||||
|
||||
|
||||
def _deployment_passes_through_anthropic_messages(model_info: object) -> bool:
|
||||
|
|
@ -533,7 +564,7 @@ def anthropic_messages_handler(
|
|||
_shared_kwargs: Final = dict(
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
model=model,
|
||||
model=original_model,
|
||||
metadata=metadata,
|
||||
stop_sequences=stop_sequences,
|
||||
stream=stream,
|
||||
|
|
@ -551,7 +582,7 @@ def anthropic_messages_handler(
|
|||
custom_llm_provider=custom_llm_provider,
|
||||
**kwargs,
|
||||
)
|
||||
if _should_route_to_responses_api(custom_llm_provider):
|
||||
if _should_route_to_responses_api(custom_llm_provider, original_model, model):
|
||||
return LiteLLMMessagesToResponsesAPIHandler.anthropic_messages_handler(**_shared_kwargs)
|
||||
|
||||
# The in-gateway context_management polyfill runs inside
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class AnthropicMessagesRequestUtils:
|
|||
filtered_params: Final = {k: v for k, v in params.items() if k in valid_keys and v is not None}
|
||||
if model is not None:
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
|
||||
|
||||
AnthropicConfig._maybe_drop_speed_param(
|
||||
model=model,
|
||||
|
|
@ -51,6 +52,16 @@ class AnthropicMessagesRequestUtils:
|
|||
drop_params=drop_params,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
for param in ("temperature", "top_p", "top_k"):
|
||||
if param in filtered_params:
|
||||
AnthropicModelInfo._apply_sampling_param( # pyright: ignore[reportPrivateUsage] # same gating the /chat/completions path applies; forking it would drift
|
||||
optional_params=filtered_params,
|
||||
model=model,
|
||||
param=param,
|
||||
value=filtered_params.pop(param),
|
||||
drop_params=drop_params,
|
||||
output_key=param,
|
||||
)
|
||||
return cast(AnthropicMessagesRequestOptionalParams, filtered_params)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.types.llms.anthropic_messages.anthropic_response import (
|
|||
)
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
|
||||
from ..utils import local_model_name
|
||||
from .streaming_iterator import AnthropicResponsesStreamWrapper
|
||||
from .transformation import LiteLLMAnthropicToResponsesAPIAdapter
|
||||
|
||||
|
|
@ -179,7 +180,9 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
result: Final = await litellm.aresponses(**responses_kwargs)
|
||||
|
||||
if stream:
|
||||
wrapper: Final = AnthropicResponsesStreamWrapper(responses_stream=result, model=model)
|
||||
wrapper: Final = AnthropicResponsesStreamWrapper(
|
||||
responses_stream=result, model=local_model_name(model, kwargs.get("custom_llm_provider"))
|
||||
)
|
||||
return wrapper.async_anthropic_sse_wrapper()
|
||||
|
||||
if not isinstance(result, ResponsesAPIResponse):
|
||||
|
|
@ -257,7 +260,9 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
result: Final = litellm.responses(**responses_kwargs)
|
||||
|
||||
if stream:
|
||||
wrapper: Final = AnthropicResponsesStreamWrapper(responses_stream=result, model=model)
|
||||
wrapper: Final = AnthropicResponsesStreamWrapper(
|
||||
responses_stream=result, model=local_model_name(model, kwargs.get("custom_llm_provider"))
|
||||
)
|
||||
return wrapper.async_anthropic_sse_wrapper()
|
||||
|
||||
if not isinstance(result, ResponsesAPIResponse):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ def prompt_cache_key_from_user_id(user_id: object) -> str | None:
|
|||
return str(user_id)[:OPENAI_MAX_PROMPT_CACHE_KEY_LENGTH] or None
|
||||
|
||||
|
||||
def local_model_name(model: str, custom_llm_provider: object) -> str:
|
||||
"""The id the provider itself knows, for reporting back to the caller in ``message_start``."""
|
||||
return model.removeprefix(f"{custom_llm_provider}/") if isinstance(custom_llm_provider, str) else model
|
||||
|
||||
|
||||
def is_reasoning_auto_summary_enabled() -> bool:
|
||||
"""Check whether the default 'summary: detailed' injection is enabled (opt-in)."""
|
||||
return litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
|
||||
|
|
|
|||
|
|
@ -4881,6 +4881,38 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
},
|
||||
"azure/gpt-audio-mini": {
|
||||
"deprecation_date": "2027-04-06",
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
"input_cost_per_token": 6e-07,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 16384,
|
||||
"max_tokens": 16384,
|
||||
"mode": "chat",
|
||||
"output_cost_per_audio_token": 2e-05,
|
||||
"output_cost_per_token": 2.4e-06,
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_reasoning": false,
|
||||
"supports_response_schema": false,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
},
|
||||
"azure/gpt-audio-mini-2025-10-06": {
|
||||
"deprecation_date": "2027-04-06",
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
|
|
@ -5094,6 +5126,38 @@
|
|||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"azure/gpt-realtime-mini": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_token_cost": 6e-08,
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
"input_cost_per_image": 8e-07,
|
||||
"input_cost_per_token": 6e-07,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 32000,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "realtime",
|
||||
"output_cost_per_audio_token": 2e-05,
|
||||
"output_cost_per_token": 2.4e-06,
|
||||
"supported_endpoints": [
|
||||
"/v1/realtime"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_input": true,
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"azure/gpt-realtime-mini-2025-10-06": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_token_cost": 6e-08,
|
||||
|
|
@ -19498,106 +19562,6 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini-3.1-flash-lite-image": {
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"litellm_provider": "vertex_ai-language-models",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": false,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_response_schema": false,
|
||||
"supports_reasoning": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"gemini/gemini-3.1-flash-lite-image": {
|
||||
"rpm": 1000,
|
||||
"tpm": 4000000,
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"input_cost_per_token_batches": 1.25e-07,
|
||||
"litellm_provider": "gemini",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"output_cost_per_token_batches": 7.5e-07,
|
||||
"source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite-image",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_response_schema": false,
|
||||
"supports_reasoning": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"vertex_ai/gemini-3.1-flash-lite-image": {
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"litellm_provider": "vertex_ai-language-models",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": false,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_response_schema": false,
|
||||
"supports_reasoning": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"gemini-3.1-flash-image": {
|
||||
"deprecation_date": "2027-05-28",
|
||||
"input_cost_per_image": 0.00056,
|
||||
|
|
@ -19675,6 +19639,44 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini-3.1-flash-lite-image": {
|
||||
"cache_read_input_token_cost": 2.5e-08,
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"input_cost_per_token_batches": 1.25e-07,
|
||||
"litellm_provider": "vertex_ai-language-models",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"output_cost_per_token_batches": 7.5e-07,
|
||||
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": false,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": false,
|
||||
"supports_response_schema": false,
|
||||
"supports_system_messages": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"gemini-3.1-flash-lite-preview": {
|
||||
"cache_read_input_token_cost": 2.5e-08,
|
||||
"input_cost_per_audio_token": 5e-07,
|
||||
|
|
@ -21505,6 +21507,42 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini/gemini-3.1-flash-lite-image": {
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"input_cost_per_token_batches": 1.25e-07,
|
||||
"litellm_provider": "gemini",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"output_cost_per_token_batches": 7.5e-07,
|
||||
"rpm": 1000,
|
||||
"source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite-image",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_reasoning": false,
|
||||
"supports_response_schema": false,
|
||||
"supports_system_messages": true,
|
||||
"supports_vision": true,
|
||||
"tpm": 4000000
|
||||
},
|
||||
"gemini/deep-research-pro-preview-12-2025": {
|
||||
"input_cost_per_image": 0.0011,
|
||||
"input_cost_per_token": 2e-06,
|
||||
|
|
@ -26041,33 +26079,33 @@
|
|||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"gpt-5.6": {
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_flex": 6.25e-06,
|
||||
"cache_creation_input_token_cost_flex": 3.125e-06,
|
||||
"cache_creation_input_token_cost_priority": 1.25e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": 5e-07,
|
||||
"cache_read_input_token_cost_flex": 2.5e-07,
|
||||
"cache_read_input_token_cost_priority": 1e-06,
|
||||
"input_cost_per_token": 5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"input_cost_per_token_above_272k_tokens_flex": 5e-06,
|
||||
"input_cost_per_token_batches": 2.5e-06,
|
||||
"input_cost_per_token_flex": 2.5e-06,
|
||||
"input_cost_per_token_priority": 1e-05,
|
||||
"cache_creation_input_token_cost": 5e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1e-05,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_flex": 5e-06,
|
||||
"cache_creation_input_token_cost_flex": 2.5e-06,
|
||||
"cache_creation_input_token_cost_priority": 1e-05,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 8e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": 4e-07,
|
||||
"cache_read_input_token_cost_flex": 2e-07,
|
||||
"cache_read_input_token_cost_priority": 8e-07,
|
||||
"input_cost_per_token": 4e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 8e-06,
|
||||
"input_cost_per_token_above_272k_tokens_flex": 4e-06,
|
||||
"input_cost_per_token_batches": 2e-06,
|
||||
"input_cost_per_token_flex": 2e-06,
|
||||
"input_cost_per_token_priority": 8e-06,
|
||||
"litellm_provider": "openai",
|
||||
"max_input_tokens": 922000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 2.25e-05,
|
||||
"output_cost_per_token_batches": 1.5e-05,
|
||||
"output_cost_per_token_flex": 1.5e-05,
|
||||
"output_cost_per_token_priority": 6e-05,
|
||||
"output_cost_per_token": 2e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 1.5e-05,
|
||||
"output_cost_per_token_batches": 1e-05,
|
||||
"output_cost_per_token_flex": 1e-05,
|
||||
"output_cost_per_token_priority": 4e-05,
|
||||
"regional_processing_uplift_multiplier_eu": 1.1,
|
||||
"regional_processing_uplift_multiplier_us": 1.1,
|
||||
"search_context_cost_per_query": {
|
||||
|
|
@ -26104,33 +26142,33 @@
|
|||
"supports_xhigh_reasoning_effort": true
|
||||
},
|
||||
"gpt-5.6-sol": {
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_flex": 6.25e-06,
|
||||
"cache_creation_input_token_cost_flex": 3.125e-06,
|
||||
"cache_creation_input_token_cost_priority": 1.25e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": 5e-07,
|
||||
"cache_read_input_token_cost_flex": 2.5e-07,
|
||||
"cache_read_input_token_cost_priority": 1e-06,
|
||||
"input_cost_per_token": 5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"input_cost_per_token_above_272k_tokens_flex": 5e-06,
|
||||
"input_cost_per_token_batches": 2.5e-06,
|
||||
"input_cost_per_token_flex": 2.5e-06,
|
||||
"input_cost_per_token_priority": 1e-05,
|
||||
"cache_creation_input_token_cost": 5e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1e-05,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_flex": 5e-06,
|
||||
"cache_creation_input_token_cost_flex": 2.5e-06,
|
||||
"cache_creation_input_token_cost_priority": 1e-05,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 8e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": 4e-07,
|
||||
"cache_read_input_token_cost_flex": 2e-07,
|
||||
"cache_read_input_token_cost_priority": 8e-07,
|
||||
"input_cost_per_token": 4e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 8e-06,
|
||||
"input_cost_per_token_above_272k_tokens_flex": 4e-06,
|
||||
"input_cost_per_token_batches": 2e-06,
|
||||
"input_cost_per_token_flex": 2e-06,
|
||||
"input_cost_per_token_priority": 8e-06,
|
||||
"litellm_provider": "openai",
|
||||
"max_input_tokens": 922000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 2.25e-05,
|
||||
"output_cost_per_token_batches": 1.5e-05,
|
||||
"output_cost_per_token_flex": 1.5e-05,
|
||||
"output_cost_per_token_priority": 6e-05,
|
||||
"output_cost_per_token": 2e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 1.5e-05,
|
||||
"output_cost_per_token_batches": 1e-05,
|
||||
"output_cost_per_token_flex": 1e-05,
|
||||
"output_cost_per_token_priority": 4e-05,
|
||||
"regional_processing_uplift_multiplier_eu": 1.1,
|
||||
"regional_processing_uplift_multiplier_us": 1.1,
|
||||
"search_context_cost_per_query": {
|
||||
|
|
@ -26372,19 +26410,19 @@
|
|||
"supports_parallel_function_calling": true
|
||||
},
|
||||
"daybreak-blue-latest": {
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
||||
"input_cost_per_token": 5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"cache_creation_input_token_cost": 5e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1e-05,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 8e-07,
|
||||
"input_cost_per_token": 4e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 8e-06,
|
||||
"litellm_provider": "openai",
|
||||
"max_input_tokens": 1050000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"output_cost_per_token": 2e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 3e-05,
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
|
|
@ -41066,6 +41104,44 @@
|
|||
"supports_reasoning": false,
|
||||
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models"
|
||||
},
|
||||
"vertex_ai/gemini-3.1-flash-lite-image": {
|
||||
"cache_read_input_token_cost": 2.5e-08,
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"input_cost_per_token_batches": 1.25e-07,
|
||||
"litellm_provider": "vertex_ai-language-models",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"output_cost_per_token_batches": 7.5e-07,
|
||||
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": false,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": false,
|
||||
"supports_response_schema": false,
|
||||
"supports_system_messages": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"vertex_ai/gemini-3.1-flash-lite-preview": {
|
||||
"cache_read_input_token_cost": 2.5e-08,
|
||||
"input_cost_per_audio_token": 5e-07,
|
||||
|
|
@ -48605,6 +48681,156 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"us.openai.gpt-5.6-sol": {
|
||||
"input_cost_per_token": 5.5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1.1e-05,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.375e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
|
||||
"output_cost_per_token": 3.3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.95e-05,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"global.openai.gpt-5.6-sol": {
|
||||
"input_cost_per_token": 5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
||||
"output_cost_per_token": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"us.openai.gpt-5.6-terra": {
|
||||
"input_cost_per_token": 2.2e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 4.4e-06,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5.5e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4.4e-07,
|
||||
"output_cost_per_token": 1.32e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 1.98e-05,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"global.openai.gpt-5.6-terra": {
|
||||
"input_cost_per_token": 2e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 4e-06,
|
||||
"cache_creation_input_token_cost": 2.5e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5e-06,
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4e-07,
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 1.8e-05,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"us.openai.gpt-5.6-luna": {
|
||||
"input_cost_per_token": 2.2e-07,
|
||||
"input_cost_per_token_above_272k_tokens": 4.4e-07,
|
||||
"cache_creation_input_token_cost": 2.75e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5.5e-07,
|
||||
"cache_read_input_token_cost": 2.2e-08,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4.4e-08,
|
||||
"output_cost_per_token": 1.32e-06,
|
||||
"output_cost_per_token_above_272k_tokens": 1.98e-06,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"global.openai.gpt-5.6-luna": {
|
||||
"input_cost_per_token": 2e-07,
|
||||
"input_cost_per_token_above_272k_tokens": 4e-07,
|
||||
"cache_creation_input_token_cost": 2.5e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5e-07,
|
||||
"cache_read_input_token_cost": 2e-08,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4e-08,
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"output_cost_per_token_above_272k_tokens": 1.8e-06,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"bedrock_mantle/openai.gpt-5.5": {
|
||||
"input_cost_per_token": 5.5e-06,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from litellm.proxy._types import (
|
|||
SpecialMCPServerName,
|
||||
SpecialMCPServerNames,
|
||||
UserAPIKeyAuth,
|
||||
user_api_key_has_admin_view,
|
||||
)
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
from litellm.proxy.auth.user_api_key_auth import (
|
||||
|
|
@ -1785,11 +1786,14 @@ class MCPRequestHandler:
|
|||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
# An OPEN channel (allow_all_keys, the user's own BYOM) makes the server REACHABLE through the
|
||||
# user, though no grant source names it — without this the union returns [], listable but
|
||||
# uninvokable. Reachability is ALL it confers, NOT a ceiling waiver: the user's own
|
||||
# mcp_tool_permissions and org tool ceiling still bind, exactly as a key's do on an allow_all server.
|
||||
reachable_via_open_channel: Final = server_id in await global_mcp_server_manager.operator_open_server_ids(auth)
|
||||
# An OPEN channel (allow_all_keys, the user's own BYOM, an unscoped admin-view role) makes the
|
||||
# server REACHABLE through the user, though no grant source names it — without this the union
|
||||
# returns [], listable but uninvokable. Reachability is ALL it confers, NOT a ceiling waiver:
|
||||
# the user's own mcp_tool_permissions and org tool ceiling still bind, exactly as a key's do
|
||||
# on an allow_all server or an admin key's do on any server.
|
||||
reachable_via_open_channel: Final = server_id in await global_mcp_server_manager.operator_open_server_ids(
|
||||
auth
|
||||
) or await MCPRequestHandler.admin_view_unscoped(auth)
|
||||
|
||||
allowed: Final[set[str]] = set()
|
||||
for source, granted in await MCPRequestHandler.admitted_source_grants(auth):
|
||||
|
|
@ -2723,6 +2727,32 @@ class MCPRequestHandler:
|
|||
entitled_servers: Final = await MCPRequestHandler._get_allowed_mcp_servers_for_user(user_api_key_auth)
|
||||
return entitled_servers is None or len(entitled_servers) > 0
|
||||
|
||||
@staticmethod
|
||||
async def admin_view_unscoped(user_api_key_auth: UserAPIKeyAuth | None = None) -> bool:
|
||||
"""Whether this principal's admin-view role grants the unscoped MCP resolution, whatever
|
||||
credential carries it (admin key, dashboard session, or OAuth-admitted session subject).
|
||||
|
||||
Two bounds disqualify, one per ownership of the row. A CREDENTIAL's explicit
|
||||
``object_permission.mcp_servers`` scope wins even for admins, including the empty list. An
|
||||
admitted subject's object_permission is the user's own row, whose ``mcp_servers`` column is
|
||||
[] by DB default, so for that shape the row binds through the entitlement ceiling instead
|
||||
(any non-empty entitlement, or an unresolved one, disqualifies), exactly as
|
||||
``operator_open_server_ids`` reads the same row. The one owner of this predicate: the
|
||||
server-axis registry resolution in ``get_allowed_mcp_servers`` and the tools-axis open
|
||||
channel in ``_resolve_admitted_subject_tools`` both consult it, so the two axes cannot
|
||||
disagree."""
|
||||
if user_api_key_auth is None or not user_api_key_has_admin_view(user_api_key_auth):
|
||||
return False
|
||||
object_permission: Final = user_api_key_auth.object_permission
|
||||
credential_scoped: Final = (
|
||||
not _is_mcp_admitted_user_subject(user_api_key_auth)
|
||||
and object_permission is not None
|
||||
and object_permission.mcp_servers is not None
|
||||
)
|
||||
if credential_scoped:
|
||||
return False
|
||||
return not await MCPRequestHandler._user_places_mcp_ceiling(user_api_key_auth)
|
||||
|
||||
@staticmethod
|
||||
async def _apply_user_tool_ceiling(
|
||||
allowed_tools: Sequence[str] | None,
|
||||
|
|
|
|||
|
|
@ -2943,17 +2943,14 @@ class MCPServerManager:
|
|||
2. If admin and no object_permission, return all servers
|
||||
3. Otherwise, use standard permission checks
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
|
||||
|
||||
allow_all_server_ids: Final = self.get_allow_all_keys_server_ids()
|
||||
|
||||
# A keyless admitted subject is resolved per grant source, and channel decisions that are
|
||||
# absolute for a scoped KEY credential are not absolute for it: its own opt-out silences its
|
||||
# own source (handled per source in the resolver), never its teams' grants, and its admin
|
||||
# role does not swallow the grant model — a session bearer is a third-party client
|
||||
# credential, not the dashboard, so an admin signing in through the connect flow gets their
|
||||
# grants like anyone else rather than handing the client the full registry ahead of every
|
||||
# per-team org ceiling.
|
||||
# own source (handled per source in the resolver), never its teams' grants. Its admin role
|
||||
# rides the HUMAN, not the credential: an admin's session resolves the same registry their
|
||||
# dashboard shows (connect-page parity), bounded like an admin key by explicit
|
||||
# object_permission scope, the entitlement ceiling, and the session resource scope below.
|
||||
is_admitted_subject: Final = _is_mcp_admitted_user_subject(user_api_key_auth)
|
||||
|
||||
# The key explicitly opted out of every MCP server. Return zero before
|
||||
|
|
@ -2982,26 +2979,16 @@ class MCPServerManager:
|
|||
)
|
||||
|
||||
try:
|
||||
# If admin but NO explicit object permission, get all servers (never for an admitted
|
||||
# subject — see is_admitted_subject above)
|
||||
if (
|
||||
user_api_key_auth
|
||||
and not is_admitted_subject
|
||||
and _user_has_admin_view(user_api_key_auth)
|
||||
and not has_explicit_object_permission
|
||||
# An entitlement attached to the HUMAN binds them whatever their role: it is the
|
||||
# person's scope, not the credential's, so an admin role is not a waiver of it. An
|
||||
# UNRESOLVED entitlement also skips the shortcut, so the resolver denies rather than
|
||||
# handing over the whole registry on a transient fault.
|
||||
and not await MCPRequestHandler._user_places_mcp_ceiling(user_api_key_auth)
|
||||
):
|
||||
verbose_logger.debug("Admin user without explicit object_permission - returning all servers")
|
||||
return list(self.get_registry().keys())
|
||||
|
||||
# Get allowed servers from object permissions (respects object_permission even for admins)
|
||||
allowed_mcp_servers: Final = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth)
|
||||
verbose_logger.debug("Allowed MCP Servers for user api key auth: %s", allowed_mcp_servers)
|
||||
combined_servers: Final = set(allowed_mcp_servers)
|
||||
# Admin view with no explicit object permission and no entitlement ceiling resolves the
|
||||
# whole registry, for keys AND admitted session subjects alike (one predicate owns the
|
||||
# question). Seeded into the union rather than returned early so the session resource
|
||||
# scope below still bounds a per-server envelope held by an admin.
|
||||
combined_servers: Final = (
|
||||
set(self.get_registry().keys())
|
||||
if await MCPRequestHandler.admin_view_unscoped(user_api_key_auth)
|
||||
else set(await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth))
|
||||
)
|
||||
verbose_logger.debug("Allowed MCP Servers for user api key auth: %s", combined_servers)
|
||||
combined_servers.update(
|
||||
await self.operator_open_server_ids(
|
||||
user_api_key_auth,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from litellm.constants import LITELLM_PROXY_ADMIN_NAME
|
|||
from litellm.litellm_core_utils.ptu_pricing import (
|
||||
CUSTOM_PRICING_FIELDS,
|
||||
PTU_EMPTIED_PRICING_FIELDS,
|
||||
PTU_MODEL_INFO_FIELDS,
|
||||
PTU_ZEROED_PRICING_FIELDS,
|
||||
PTU_ZEROED_TABLE_FIELDS,
|
||||
SEARCH_CONTEXT_SIZES,
|
||||
|
|
@ -247,7 +248,6 @@ def _raise_on_strategy_router_write_violation(
|
|||
)
|
||||
|
||||
|
||||
_PTU_MODEL_INFO_FIELDS: Final = ("ptu_count", "cost_per_ptu_per_hour", "ptu_effective_from", "ptu_effective_to")
|
||||
_PTU_PRICED_PAIR: Final = frozenset({"ptu_count", "cost_per_ptu_per_hour"})
|
||||
|
||||
|
||||
|
|
@ -261,7 +261,7 @@ def _explicitly_cleared_ptu_fields(model_info: ModelInfo | None) -> frozenset[st
|
|||
return frozenset()
|
||||
return frozenset(
|
||||
field
|
||||
for field in _PTU_MODEL_INFO_FIELDS
|
||||
for field in PTU_MODEL_INFO_FIELDS
|
||||
if field in model_info.model_fields_set and getattr(model_info, field) is None
|
||||
)
|
||||
|
||||
|
|
@ -294,7 +294,7 @@ def _raise_if_ptu_cost_attribution_disabled(incoming_model_info: Mapping[str, ob
|
|||
"""
|
||||
if is_ptu_cost_attribution_enabled():
|
||||
return
|
||||
supplied: Final = tuple(field for field in _PTU_MODEL_INFO_FIELDS if incoming_model_info.get(field) is not None)
|
||||
supplied: Final = tuple(field for field in PTU_MODEL_INFO_FIELDS if incoming_model_info.get(field) is not None)
|
||||
if not supplied:
|
||||
return
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
|
|||
from litellm.litellm_core_utils.dd_tracing import tracer
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||||
from litellm.litellm_core_utils.ptu_pricing import (
|
||||
PTU_COST_ATTRIBUTION_ENV_VAR,
|
||||
declares_ptu,
|
||||
is_ptu_cost_attribution_enabled,
|
||||
ptu_config_error,
|
||||
ptu_identity_error,
|
||||
|
|
@ -8234,6 +8236,21 @@ class Router:
|
|||
)
|
||||
duplicate_ids: Final = frozenset(model_id for model_id in declared_ids if declared_ids.count(model_id) > 1)
|
||||
|
||||
ptu_declared: Final = tuple(
|
||||
str(entry.get("model_name"))
|
||||
for entry in original_model_list
|
||||
if isinstance(entry.get("model_info"), dict)
|
||||
and entry["model_info"].get("db_model") is not True
|
||||
and declares_ptu(entry["model_info"])
|
||||
)
|
||||
if ptu_declared and not is_ptu_cost_attribution_enabled():
|
||||
verbose_router_logger.warning(
|
||||
"PTU fields are set on config.yaml deployment(s) %s, but PTU cost attribution is disabled, so no "
|
||||
"flat cost accrues and this traffic is billed per token. Set %s=True to enable it",
|
||||
", ".join(ptu_declared),
|
||||
PTU_COST_ATTRIBUTION_ENV_VAR,
|
||||
)
|
||||
|
||||
for model in original_model_list:
|
||||
_model_name = model.pop("model_name")
|
||||
_litellm_params = model.pop("litellm_params")
|
||||
|
|
|
|||
|
|
@ -4881,6 +4881,38 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
},
|
||||
"azure/gpt-audio-mini": {
|
||||
"deprecation_date": "2027-04-06",
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
"input_cost_per_token": 6e-07,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 16384,
|
||||
"max_tokens": 16384,
|
||||
"mode": "chat",
|
||||
"output_cost_per_audio_token": 2e-05,
|
||||
"output_cost_per_token": 2.4e-06,
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_reasoning": false,
|
||||
"supports_response_schema": false,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
},
|
||||
"azure/gpt-audio-mini-2025-10-06": {
|
||||
"deprecation_date": "2027-04-06",
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
|
|
@ -5094,6 +5126,38 @@
|
|||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"azure/gpt-realtime-mini": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_token_cost": 6e-08,
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
"input_cost_per_image": 8e-07,
|
||||
"input_cost_per_token": 6e-07,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 32000,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "realtime",
|
||||
"output_cost_per_audio_token": 2e-05,
|
||||
"output_cost_per_token": 2.4e-06,
|
||||
"supported_endpoints": [
|
||||
"/v1/realtime"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"supports_audio_input": true,
|
||||
"supports_audio_output": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"azure/gpt-realtime-mini-2025-10-06": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_token_cost": 6e-08,
|
||||
|
|
@ -19498,106 +19562,6 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini-3.1-flash-lite-image": {
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"litellm_provider": "vertex_ai-language-models",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": false,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_response_schema": false,
|
||||
"supports_reasoning": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"gemini/gemini-3.1-flash-lite-image": {
|
||||
"rpm": 1000,
|
||||
"tpm": 4000000,
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"input_cost_per_token_batches": 1.25e-07,
|
||||
"litellm_provider": "gemini",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"output_cost_per_token_batches": 7.5e-07,
|
||||
"source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite-image",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_response_schema": false,
|
||||
"supports_reasoning": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"vertex_ai/gemini-3.1-flash-lite-image": {
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"litellm_provider": "vertex_ai-language-models",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": false,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_response_schema": false,
|
||||
"supports_reasoning": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"gemini-3.1-flash-image": {
|
||||
"deprecation_date": "2027-05-28",
|
||||
"input_cost_per_image": 0.00056,
|
||||
|
|
@ -19675,6 +19639,44 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini-3.1-flash-lite-image": {
|
||||
"cache_read_input_token_cost": 2.5e-08,
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"input_cost_per_token_batches": 1.25e-07,
|
||||
"litellm_provider": "vertex_ai-language-models",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"output_cost_per_token_batches": 7.5e-07,
|
||||
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": false,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": false,
|
||||
"supports_response_schema": false,
|
||||
"supports_system_messages": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"gemini-3.1-flash-lite-preview": {
|
||||
"cache_read_input_token_cost": 2.5e-08,
|
||||
"input_cost_per_audio_token": 5e-07,
|
||||
|
|
@ -21505,6 +21507,42 @@
|
|||
},
|
||||
"web_search_billing_unit": "per_query"
|
||||
},
|
||||
"gemini/gemini-3.1-flash-lite-image": {
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"input_cost_per_token_batches": 1.25e-07,
|
||||
"litellm_provider": "gemini",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"output_cost_per_token_batches": 7.5e-07,
|
||||
"rpm": 1000,
|
||||
"source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite-image",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_reasoning": false,
|
||||
"supports_response_schema": false,
|
||||
"supports_system_messages": true,
|
||||
"supports_vision": true,
|
||||
"tpm": 4000000
|
||||
},
|
||||
"gemini/deep-research-pro-preview-12-2025": {
|
||||
"input_cost_per_image": 0.0011,
|
||||
"input_cost_per_token": 2e-06,
|
||||
|
|
@ -26041,33 +26079,33 @@
|
|||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"gpt-5.6": {
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_flex": 6.25e-06,
|
||||
"cache_creation_input_token_cost_flex": 3.125e-06,
|
||||
"cache_creation_input_token_cost_priority": 1.25e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": 5e-07,
|
||||
"cache_read_input_token_cost_flex": 2.5e-07,
|
||||
"cache_read_input_token_cost_priority": 1e-06,
|
||||
"input_cost_per_token": 5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"input_cost_per_token_above_272k_tokens_flex": 5e-06,
|
||||
"input_cost_per_token_batches": 2.5e-06,
|
||||
"input_cost_per_token_flex": 2.5e-06,
|
||||
"input_cost_per_token_priority": 1e-05,
|
||||
"cache_creation_input_token_cost": 5e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1e-05,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_flex": 5e-06,
|
||||
"cache_creation_input_token_cost_flex": 2.5e-06,
|
||||
"cache_creation_input_token_cost_priority": 1e-05,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 8e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": 4e-07,
|
||||
"cache_read_input_token_cost_flex": 2e-07,
|
||||
"cache_read_input_token_cost_priority": 8e-07,
|
||||
"input_cost_per_token": 4e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 8e-06,
|
||||
"input_cost_per_token_above_272k_tokens_flex": 4e-06,
|
||||
"input_cost_per_token_batches": 2e-06,
|
||||
"input_cost_per_token_flex": 2e-06,
|
||||
"input_cost_per_token_priority": 8e-06,
|
||||
"litellm_provider": "openai",
|
||||
"max_input_tokens": 922000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 2.25e-05,
|
||||
"output_cost_per_token_batches": 1.5e-05,
|
||||
"output_cost_per_token_flex": 1.5e-05,
|
||||
"output_cost_per_token_priority": 6e-05,
|
||||
"output_cost_per_token": 2e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 1.5e-05,
|
||||
"output_cost_per_token_batches": 1e-05,
|
||||
"output_cost_per_token_flex": 1e-05,
|
||||
"output_cost_per_token_priority": 4e-05,
|
||||
"regional_processing_uplift_multiplier_eu": 1.1,
|
||||
"regional_processing_uplift_multiplier_us": 1.1,
|
||||
"search_context_cost_per_query": {
|
||||
|
|
@ -26104,33 +26142,33 @@
|
|||
"supports_xhigh_reasoning_effort": true
|
||||
},
|
||||
"gpt-5.6-sol": {
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_flex": 6.25e-06,
|
||||
"cache_creation_input_token_cost_flex": 3.125e-06,
|
||||
"cache_creation_input_token_cost_priority": 1.25e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": 5e-07,
|
||||
"cache_read_input_token_cost_flex": 2.5e-07,
|
||||
"cache_read_input_token_cost_priority": 1e-06,
|
||||
"input_cost_per_token": 5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"input_cost_per_token_above_272k_tokens_flex": 5e-06,
|
||||
"input_cost_per_token_batches": 2.5e-06,
|
||||
"input_cost_per_token_flex": 2.5e-06,
|
||||
"input_cost_per_token_priority": 1e-05,
|
||||
"cache_creation_input_token_cost": 5e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1e-05,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_flex": 5e-06,
|
||||
"cache_creation_input_token_cost_flex": 2.5e-06,
|
||||
"cache_creation_input_token_cost_priority": 1e-05,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 8e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": 4e-07,
|
||||
"cache_read_input_token_cost_flex": 2e-07,
|
||||
"cache_read_input_token_cost_priority": 8e-07,
|
||||
"input_cost_per_token": 4e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 8e-06,
|
||||
"input_cost_per_token_above_272k_tokens_flex": 4e-06,
|
||||
"input_cost_per_token_batches": 2e-06,
|
||||
"input_cost_per_token_flex": 2e-06,
|
||||
"input_cost_per_token_priority": 8e-06,
|
||||
"litellm_provider": "openai",
|
||||
"max_input_tokens": 922000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 2.25e-05,
|
||||
"output_cost_per_token_batches": 1.5e-05,
|
||||
"output_cost_per_token_flex": 1.5e-05,
|
||||
"output_cost_per_token_priority": 6e-05,
|
||||
"output_cost_per_token": 2e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 1.5e-05,
|
||||
"output_cost_per_token_batches": 1e-05,
|
||||
"output_cost_per_token_flex": 1e-05,
|
||||
"output_cost_per_token_priority": 4e-05,
|
||||
"regional_processing_uplift_multiplier_eu": 1.1,
|
||||
"regional_processing_uplift_multiplier_us": 1.1,
|
||||
"search_context_cost_per_query": {
|
||||
|
|
@ -26372,19 +26410,19 @@
|
|||
"supports_parallel_function_calling": true
|
||||
},
|
||||
"daybreak-blue-latest": {
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
||||
"input_cost_per_token": 5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"cache_creation_input_token_cost": 5e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1e-05,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 8e-07,
|
||||
"input_cost_per_token": 4e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 8e-06,
|
||||
"litellm_provider": "openai",
|
||||
"max_input_tokens": 1050000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"output_cost_per_token": 2e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 3e-05,
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
|
|
@ -41066,6 +41104,44 @@
|
|||
"supports_reasoning": false,
|
||||
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models"
|
||||
},
|
||||
"vertex_ai/gemini-3.1-flash-lite-image": {
|
||||
"cache_read_input_token_cost": 2.5e-08,
|
||||
"input_cost_per_image": 0.00028,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"input_cost_per_token_batches": 1.25e-07,
|
||||
"litellm_provider": "vertex_ai-language-models",
|
||||
"max_input_tokens": 65536,
|
||||
"max_output_tokens": 4096,
|
||||
"max_tokens": 4096,
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"output_cost_per_token_batches": 7.5e-07,
|
||||
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supports_function_calling": false,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": false,
|
||||
"supports_response_schema": false,
|
||||
"supports_system_messages": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"vertex_ai/gemini-3.1-flash-lite-preview": {
|
||||
"cache_read_input_token_cost": 2.5e-08,
|
||||
"input_cost_per_audio_token": 5e-07,
|
||||
|
|
@ -48605,6 +48681,156 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"us.openai.gpt-5.6-sol": {
|
||||
"input_cost_per_token": 5.5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1.1e-05,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.375e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
|
||||
"output_cost_per_token": 3.3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.95e-05,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"global.openai.gpt-5.6-sol": {
|
||||
"input_cost_per_token": 5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
||||
"output_cost_per_token": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"us.openai.gpt-5.6-terra": {
|
||||
"input_cost_per_token": 2.2e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 4.4e-06,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5.5e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4.4e-07,
|
||||
"output_cost_per_token": 1.32e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 1.98e-05,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"global.openai.gpt-5.6-terra": {
|
||||
"input_cost_per_token": 2e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 4e-06,
|
||||
"cache_creation_input_token_cost": 2.5e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5e-06,
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4e-07,
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 1.8e-05,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"us.openai.gpt-5.6-luna": {
|
||||
"input_cost_per_token": 2.2e-07,
|
||||
"input_cost_per_token_above_272k_tokens": 4.4e-07,
|
||||
"cache_creation_input_token_cost": 2.75e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5.5e-07,
|
||||
"cache_read_input_token_cost": 2.2e-08,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4.4e-08,
|
||||
"output_cost_per_token": 1.32e-06,
|
||||
"output_cost_per_token_above_272k_tokens": 1.98e-06,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"global.openai.gpt-5.6-luna": {
|
||||
"input_cost_per_token": 2e-07,
|
||||
"input_cost_per_token_above_272k_tokens": 4e-07,
|
||||
"cache_creation_input_token_cost": 2.5e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 5e-07,
|
||||
"cache_read_input_token_cost": 2e-08,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 4e-08,
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"output_cost_per_token_above_272k_tokens": 1.8e-06,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"bedrock_mantle/openai.gpt-5.5": {
|
||||
"input_cost_per_token": 5.5e-06,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
|
|||
|
|
@ -341,9 +341,13 @@ filterwarnings = [
|
|||
paths_to_mutate = [
|
||||
"litellm/proxy/management_endpoints/",
|
||||
]
|
||||
# Only the unit tier that maps to paths_to_mutate. mutmut times and
|
||||
# coverage-maps this whole set once before mutating, so a tier that needs a
|
||||
# seeded database (tests/proxy_behavior/) kills the run before it starts, and
|
||||
# a mutation score is only meaningful against the tests that claim to cover
|
||||
# the mutated code anyway.
|
||||
tests_dir = [
|
||||
"tests/test_litellm/proxy/management_endpoints/",
|
||||
"tests/proxy_behavior/management/",
|
||||
]
|
||||
also_copy = [
|
||||
"litellm/",
|
||||
|
|
@ -360,10 +364,16 @@ mutate_only_covered_lines = true
|
|||
# - rerunning a "failed" test on a mutant would mask which mutants are killed
|
||||
# vs. survive, so reruns are wrong for mutation testing regardless.
|
||||
# - xdist is unnecessary inside mutmut (mutmut handles its own parallelism).
|
||||
# test_saml_sso.py cannot run inside mutmut's mutants/ sandbox: the copied tree
|
||||
# re-imports cryptography's hash classes under a second identity, so x509 .sign()
|
||||
# rejects the SHA256 instance the fixture builds with "Algorithm must be a
|
||||
# registered hash algorithm". Nothing to do with mutation coverage, and one
|
||||
# erroring test is enough to end the stats phase before any mutant runs.
|
||||
pytest_add_cli_args = [
|
||||
"-p", "no:retry",
|
||||
"-p", "no:rerunfailures",
|
||||
"-p", "no:xdist",
|
||||
"--ignore=tests/test_litellm/proxy/management_endpoints/test_saml_sso.py",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@
|
|||
# PT017 an `assert` on the caught error inside `except`. Nothing runs the handler when
|
||||
# the call stops raising, so the test goes green on the exact regression it was
|
||||
# written to catch. `pytest.raises` fails when the call succeeds
|
||||
# RUF043 a `match=` pattern carrying regex metacharacters in a plain string. `match=` is
|
||||
# `re.search`, so a `.` copied out of an error message is a wildcard and the block
|
||||
# accepts messages the author never meant to accept. Mark a real regex raw, wrap a
|
||||
# literal message in `re.escape`, and the pattern says which one it is
|
||||
#
|
||||
# No target-version here on purpose: it resolves from requires-python (>=3.10), so
|
||||
# 3.11-only builtins like BaseExceptionGroup are correctly flagged in a tree that
|
||||
|
|
@ -53,4 +57,5 @@ lint.select = [
|
|||
"PT017",
|
||||
"PLR0133",
|
||||
"PLW0127",
|
||||
"RUF043",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import tomllib
|
|||
from collections import defaultdict
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Final, NamedTuple
|
||||
from textwrap import dedent
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
|
@ -33,16 +34,24 @@ def load_mutmut_config() -> dict:
|
|||
return tomllib.load(f)["tool"]["mutmut"]
|
||||
|
||||
|
||||
def get_survivors() -> list[str]:
|
||||
class MutmutResults(NamedTuple):
|
||||
survivors: tuple[str, ...]
|
||||
reported: int
|
||||
|
||||
|
||||
def get_survivors() -> MutmutResults:
|
||||
proc = subprocess.run(
|
||||
[*MUTMUT_INVOCATION, "results"], capture_output=True, text=True, check=False
|
||||
)
|
||||
survivors = []
|
||||
for line in proc.stdout.splitlines():
|
||||
m = re.match(r"\s*(\S+):\s*survived\s*$", line)
|
||||
if m:
|
||||
survivors.append(m.group(1))
|
||||
return survivors
|
||||
verdicts = tuple(
|
||||
m.groups()
|
||||
for line in proc.stdout.splitlines()
|
||||
if (m := re.match(r"\s*(\S+):\s*(\S.*?)\s*$", line))
|
||||
)
|
||||
return MutmutResults(
|
||||
survivors=tuple(name for name, verdict in verdicts if verdict == "survived"),
|
||||
reported=len(verdicts),
|
||||
)
|
||||
|
||||
|
||||
def get_mutmut_show(mutant_name: str) -> str:
|
||||
|
|
@ -222,7 +231,52 @@ def render_meta_style_mutant(
|
|||
return "\n".join(out)
|
||||
|
||||
|
||||
def render(config: dict, survivors: list[str], stats: dict | None) -> str:
|
||||
RESOLVED_KEYS: Final = frozenset({"killed", "survived", "total"})
|
||||
|
||||
|
||||
def unresolved_counts(stats: dict) -> dict[str, int]:
|
||||
"""Every non-zero count that is neither a kill nor a survivor means a mutant did not
|
||||
reach the tests. Reading it as "anything else" rather than as a list of known statuses
|
||||
keeps a status this reporter has never met from passing as a clean sweep."""
|
||||
return {k: v for k, v in sorted(stats.items()) if k not in RESOLVED_KEYS and isinstance(v, int) and v > 0}
|
||||
|
||||
|
||||
def clean_sweep_is_provable(stats: dict | None) -> bool:
|
||||
"""`mutmut results` omits killed mutants, so its silence is equally consistent with a
|
||||
perfect run and with a run that never started. Only the stats file can tell them apart,
|
||||
and only when it agrees that nothing survived and every mutant reached the tests."""
|
||||
if not stats or stats.get("killed", 0) <= 0 or stats.get("survived", 0) != 0:
|
||||
return False
|
||||
return not unresolved_counts(stats)
|
||||
|
||||
|
||||
def no_survivors_verdict(results: MutmutResults, stats: dict | None) -> str:
|
||||
if clean_sweep_is_provable(stats):
|
||||
return "**No surviving mutants, and the run killed some, so the test suite caught every mutation.**"
|
||||
if stats and stats.get("survived", 0) > 0:
|
||||
return (
|
||||
f"**mutmut-cicd-stats.json counts {stats['survived']} surviving mutant(s) that "
|
||||
"`mutmut results` did not list, so the two disagree and neither can be trusted. "
|
||||
"This is not a passing score.**"
|
||||
)
|
||||
if stats and unresolved_counts(stats):
|
||||
unresolved = ", ".join(f"{v} {k.replace('_', ' ')}" for k, v in unresolved_counts(stats).items())
|
||||
return (
|
||||
f"**No survivors, but {unresolved}, so those mutants never reached the tests "
|
||||
"and the suite was not shown to catch them. This is not a passing score.**"
|
||||
)
|
||||
if stats:
|
||||
return "**Not one mutant was killed. This is not a passing score.**"
|
||||
return (
|
||||
f"**mutmut-cicd-stats.json is missing and `mutmut results` printed {results.reported} "
|
||||
"verdict(s), none of them a survivor. Since that command never lists killed mutants, a "
|
||||
"clean sweep and a run that mutated nothing look identical from here. This is not a "
|
||||
"passing score.**"
|
||||
)
|
||||
|
||||
|
||||
def render(config: dict, results: MutmutResults, stats: dict | None) -> str:
|
||||
survivors = list(results.survivors)
|
||||
by_function: dict[tuple[str, str], list[tuple[str, str]]] = defaultdict(list)
|
||||
for survivor in survivors:
|
||||
module_path, function_name, mutant_num = parse_mutant_name(survivor)
|
||||
|
|
@ -235,17 +289,8 @@ def render(config: dict, survivors: list[str], stats: dict | None) -> str:
|
|||
out.append("## Summary")
|
||||
out.append("")
|
||||
if stats:
|
||||
total = stats.get("total", 0) or sum(
|
||||
stats.get(k, 0)
|
||||
for k in (
|
||||
"killed",
|
||||
"survived",
|
||||
"no_tests",
|
||||
"skipped",
|
||||
"suspicious",
|
||||
"timeout",
|
||||
"segfault",
|
||||
)
|
||||
total = stats.get("total", 0) or (
|
||||
stats.get("killed", 0) + stats.get("survived", 0) + sum(unresolved_counts(stats).values())
|
||||
)
|
||||
killed = stats.get("killed", 0)
|
||||
survived = stats.get("survived", 0)
|
||||
|
|
@ -254,17 +299,15 @@ def render(config: dict, survivors: list[str], stats: dict | None) -> str:
|
|||
out.append(f"- Killed: **{killed}**")
|
||||
out.append(f"- Survived: **{survived}**")
|
||||
out.append(f"- Mutation score: **{score:.1f}%**")
|
||||
for k in ("no_tests", "skipped", "suspicious", "timeout", "segfault"):
|
||||
v = stats.get(k, 0)
|
||||
if v:
|
||||
out.append(f"- {k.replace('_', ' ').title()}: {v}")
|
||||
for k, v in unresolved_counts(stats).items():
|
||||
out.append(f"- {k.replace('_', ' ').title()}: {v}")
|
||||
else:
|
||||
out.append(f"- Survivors found: **{len(survivors)}**")
|
||||
out.append("- (mutmut-cicd-stats.json not available — full counts unavailable)")
|
||||
out.append("")
|
||||
|
||||
if not survivors:
|
||||
out.append("**No surviving mutants — the test suite caught every mutation.**")
|
||||
out.append(no_survivors_verdict(results, stats))
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
|
@ -407,15 +450,22 @@ def main() -> int:
|
|||
except json.JSONDecodeError as exc:
|
||||
print(f"warning: could not parse {stats_file}: {exc}", file=sys.stderr)
|
||||
|
||||
survivors = get_survivors()
|
||||
report = render(config, survivors, stats)
|
||||
results = get_survivors()
|
||||
report = render(config, results, stats)
|
||||
|
||||
out_path = ROOT / "mutation-report.md"
|
||||
out_path.write_text(report)
|
||||
print(
|
||||
f"Wrote {out_path} ({len(survivors)} survivor"
|
||||
f"{'s' if len(survivors) != 1 else ''}, {len(report)} chars)"
|
||||
f"Wrote {out_path} ({len(results.survivors)} survivor"
|
||||
f"{'s' if len(results.survivors) != 1 else ''}, {len(report)} chars)"
|
||||
)
|
||||
if not results.survivors and not clean_sweep_is_provable(stats):
|
||||
print(
|
||||
"error: nothing was shown to have been killed, so the report cannot say "
|
||||
"anything about the suite",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ signs:
|
|||
- "--detach-sign"
|
||||
- "${artifact}"
|
||||
release:
|
||||
prerelease: auto
|
||||
extra_files:
|
||||
- glob: 'terraform-registry-manifest.json'
|
||||
name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json'
|
||||
|
|
|
|||
|
|
@ -2,11 +2,22 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
Up to `0.4.0` the provider had its own version line, cut from the headings in
|
||||
this file. It now ships at the **LiteLLM version**, on every LiteLLM release
|
||||
channel, built from the same commit as the proxy (see `RELEASING.md`). The
|
||||
headings below no longer drive a release; they record what changed and which
|
||||
LiteLLM line first carried it. A change that breaks existing configurations
|
||||
or state must be called out loudly here, because the version number can no
|
||||
longer signal it.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Versioning**: the provider is now published at the LiteLLM version, from the same commit as the proxy, on every LiteLLM release (dev, rc, stable). The `0.x` line ends at `0.4.0`; a `~> 0.4` constraint will not receive further releases, so re-pin to the LiteLLM version your proxy runs (for example `~> 1.99.0`). Existing `0.x` versions remain in the registry and keep verifying
|
||||
|
||||
## [0.4.0] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -6,6 +6,18 @@ This Terraform provider allows you to manage LiteLLM resources through Infrastru
|
|||
|
||||
This directory (`terraform/provider/` in [BerriAI/litellm](https://github.com/BerriAI/litellm)) is the source of truth for the provider. [BerriAI/terraform-provider-litellm](https://github.com/BerriAI/terraform-provider-litellm) is a thin release mirror that the public Terraform Registry ingests from; do not open PRs there. Changes land here, where CI builds the provider, runs its tests, and statically audits every endpoint the provider calls against the proxy's generated OpenAPI schema (`tools/endpointaudit/`), so the provider cannot drift from the LiteLLM API silently. Releases are published by mirroring this directory into the split repo and tagging it, which triggers the goreleaser workflow there (see `RELEASING.md`)
|
||||
|
||||
## Versioning
|
||||
|
||||
The provider version **is the LiteLLM version**. Every LiteLLM release (dev, rc and stable) publishes the provider at the same version as the proxy, built from the same commit, so `1.99.0` of the provider is the one that shipped with `1.99.0` of the proxy and was audited against that proxy's API. Pin the provider to the line your proxy runs:
|
||||
|
||||
```hcl
|
||||
version = "~> 1.99.0"
|
||||
```
|
||||
|
||||
Pre-release versions (`1.99.0-rc.1`, `1.99.0-dev.1`) are published too; Terraform only selects one when it is pinned exactly.
|
||||
|
||||
Versions `0.1.0` through `0.4.0` predate this scheme and sit on their own line. They stay in the registry, but **a `~> 0.4` constraint will never pick up another release**: re-pin to the LiteLLM version to keep receiving updates.
|
||||
|
||||
## Features
|
||||
|
||||
- Manage LiteLLM model configurations
|
||||
|
|
@ -32,7 +44,7 @@ terraform {
|
|||
required_providers {
|
||||
litellm = {
|
||||
source = "BerriAI/litellm"
|
||||
version = "~> 0.1.1" #HERE UPDATE VERSION ACCORDINGLY
|
||||
version = "~> 1.99.0" # the LiteLLM version your proxy runs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -218,6 +230,6 @@ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENS
|
|||
|
||||
- Always use environment variables or secure secret management solutions to handle sensitive information like API keys and AWS credentials.
|
||||
- Refer to the comprehensive documentation in the `docs/` directory for detailed usage examples and configuration options.
|
||||
- Make sure to keep your provider version updated for the latest features and bug fixes.
|
||||
- Keep the provider version in step with the LiteLLM version your proxy runs; see [Versioning](#versioning).
|
||||
- The provider now supports AWS cross-account access with `aws_session_name` and `aws_role_name` parameters in the model resource.
|
||||
- All example configurations have been consolidated into the documentation for better organization and maintenance.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,16 @@ This document describes the release process for the LiteLLM Terraform Provider.
|
|||
|
||||
## Overview
|
||||
|
||||
Releases are automated via GitHub Actions when a version tag is pushed. The workflow builds the provider for multiple platforms, signs the artifacts with GPG, and publishes them to GitHub Releases.
|
||||
The provider is released **in lockstep with LiteLLM**: every LiteLLM release (dev, rc and stable) publishes the provider at the LiteLLM version, built from the same commit as the proxy. There is no separate provider release to cut.
|
||||
|
||||
The flow, end to end:
|
||||
|
||||
1. `BerriAI/project-releaser`'s release pipeline resolves the commit to release (`main` HEAD for dev; `main` HEAD or an operator-supplied SHA for rc/stable) and passes the release approval gate
|
||||
2. Its componentized terraform job rsyncs `terraform/provider/` from that commit into `BerriAI/terraform-provider-litellm`, commits, and pushes the tag `v<litellm version>` (for example `v1.99.0`, `v1.99.0-rc.1`, `v1.99.0-dev.1`), alongside the `terraform-aws-litellm` / `terraform-google-litellm` module mirrors which get the same tag
|
||||
3. The tag push triggers the mirror's own `Release` workflow (goreleaser): multi-platform build, GPG-signed checksums, GitHub release. It runs unattended; project-releaser does not wait for it
|
||||
4. The public Terraform Registry ingests the GitHub release as provider version `<litellm version>`
|
||||
|
||||
`terraform/provider/` only exists from LiteLLM ~1.95, so a stable patch cut from an older line skips the provider and publishes only the modules.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -68,113 +77,26 @@ Before publishing to the Terraform Registry:
|
|||
|
||||
**Note**: The public key fingerprint must match the key used to sign the provider releases.
|
||||
|
||||
## Release Steps
|
||||
## What a change needs
|
||||
|
||||
### 1. Prepare the Release
|
||||
1. **Land it in `BerriAI/litellm`.** Open a PR against `litellm_internal_staging` with the source change and a `CHANGELOG.md` entry under `[Unreleased]`. CI runs `gofmt`, `go vet`, build, tests and the endpoint-drift audit. A change that breaks existing configurations or state must say so in the changelog: the version number cannot signal it any more
|
||||
2. **Wait for the next LiteLLM release.** The nightly dev release carries it within a day; it reaches a stable version on the next stable cut
|
||||
3. **Verify** (optional): the version appears at https://registry.terraform.io/providers/BerriAI/litellm and https://github.com/BerriAI/terraform-provider-litellm/releases. If the tag is on the mirror but there is no release, the goreleaser run failed: https://github.com/BerriAI/terraform-provider-litellm/actions
|
||||
|
||||
Before creating a release:
|
||||
Locally, before opening the PR:
|
||||
|
||||
1. **Update CHANGELOG.md**
|
||||
- Move items from `[Unreleased]` section to a new version section
|
||||
- Follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format
|
||||
- Use [Semantic Versioning](https://semver.org/spec/v2.0.0.html) for version numbers
|
||||
- Include all notable changes since the last release
|
||||
```bash
|
||||
make test
|
||||
make build
|
||||
```
|
||||
|
||||
Example:
|
||||
```markdown
|
||||
## [0.1.2] - 2026-02-20
|
||||
## Out-of-band publish or recovery
|
||||
|
||||
### Added
|
||||
- New feature description
|
||||
Dispatch `Build and Publish Componentized Images + Chart` in `BerriAI/project-releaser` by hand with only `publish_terraform` enabled and the `git_ref` / `tag` of the release to (re)publish. The run waits on project-releaser's release approval, then mirrors and tags exactly as the pipeline does.
|
||||
|
||||
### Fixed
|
||||
- Bug fix description
|
||||
The mirror is push-only: do not commit or tag `BerriAI/terraform-provider-litellm` directly. The publish refuses to overwrite an existing tag; a version that failed in goreleaser is recovered by re-running the mirror's `Release` workflow for that tag, not by re-tagging.
|
||||
|
||||
### Changed
|
||||
- Changed behavior description
|
||||
```
|
||||
|
||||
2. **Verify tests pass**
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
3. **Verify the build works locally**
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
4. **Land the changes in BerriAI/litellm**
|
||||
|
||||
Open a PR to `BerriAI/litellm` updating `terraform/provider/CHANGELOG.md` (and any source changes) and merge it
|
||||
|
||||
### 2. Mirror and Tag via project-releaser
|
||||
|
||||
The provider source lives at `terraform/provider/` in `BerriAI/litellm`; `BerriAI/terraform-provider-litellm` is a thin release mirror. Do not commit or tag the mirror directly
|
||||
|
||||
Normally there is nothing to do here. `BerriAI/project-releaser`'s release pipeline runs the same check on every release except `adhoc`, nightly included: it reads the topmost released heading in `terraform/provider/CHANGELOG.md`, probes the mirror for `v<version>`, and dispatches `Publish Terraform provider` only when the changelog has moved ahead of what the mirror carries. Cutting the version heading in step 1 is therefore what releases the provider, and the next release picks it up, so the wait is a day rather than a week
|
||||
|
||||
Dispatch by hand only for an out-of-band release, or to recover a run that failed:
|
||||
|
||||
1. Go to `BerriAI/project-releaser` > **Actions** > `Publish Terraform provider`
|
||||
2. Click **Run workflow**:
|
||||
- `git_ref`: full 40-char commit SHA from `BerriAI/litellm` to release from
|
||||
- `provider_version`: the new version without the `v` prefix (e.g. `0.3.0`)
|
||||
- `dry_run`: optional; validates without pushing
|
||||
|
||||
Automatic or manual, the run waits on the `production-release` approval in `project-releaser`, then rsyncs `terraform/provider/` into the mirror repo, commits, and pushes tag `v<provider_version>`. That approval is the only one in the flow. The tag push triggers the mirror's `Release` workflow (goreleaser), which runs unattended
|
||||
|
||||
**Important**:
|
||||
- Tags must follow the format: `v<MAJOR>.<MINOR>.<PATCH>` (e.g., `v0.1.2`, `v1.0.0`)
|
||||
- The workflow refuses to overwrite an existing tag; publish a new version instead
|
||||
|
||||
### 3. Monitor the Release Workflow
|
||||
|
||||
1. Go to: https://github.com/BerriAI/terraform-provider-litellm/actions
|
||||
2. Find the "Release" workflow run for your tag
|
||||
3. Monitor the progress and check for any errors
|
||||
|
||||
The workflow will:
|
||||
- Check out the code
|
||||
- Set up Go
|
||||
- Import the GPG key
|
||||
- Run `go mod tidy`
|
||||
- Build binaries for multiple platforms (Linux, macOS, Windows, FreeBSD)
|
||||
- Create archives and checksums
|
||||
- Sign the checksums with GPG
|
||||
- Create a GitHub release
|
||||
- Upload all artifacts
|
||||
|
||||
### 4. Verify the Release
|
||||
|
||||
After the workflow completes successfully:
|
||||
|
||||
1. **Check the GitHub Release**
|
||||
- Go to: https://github.com/BerriAI/terraform-provider-litellm/releases
|
||||
- Verify the release was created with the correct version
|
||||
- Confirm all artifacts are present:
|
||||
- Binary archives for each platform
|
||||
- SHA256SUMS file
|
||||
- SHA256SUMS.sig (GPG signature)
|
||||
- terraform-registry-manifest.json
|
||||
|
||||
2. **Verify the signature** (optional)
|
||||
```bash
|
||||
# Download the checksums and signature
|
||||
wget https://github.com/BerriAI/terraform-provider-litellm/releases/download/v0.1.2/terraform-provider-litellm_0.1.2_SHA256SUMS
|
||||
wget https://github.com/BerriAI/terraform-provider-litellm/releases/download/v0.1.2/terraform-provider-litellm_0.1.2_SHA256SUMS.sig
|
||||
|
||||
# Verify the signature
|
||||
gpg --verify terraform-provider-litellm_0.1.2_SHA256SUMS.sig terraform-provider-litellm_0.1.2_SHA256SUMS
|
||||
```
|
||||
|
||||
### 5. Publish to Terraform Registry (Optional)
|
||||
|
||||
If this provider is published to the Terraform Registry:
|
||||
|
||||
1. The registry should automatically detect the new release via the GitHub webhook
|
||||
2. If not, you may need to manually trigger a sync on the Terraform Registry dashboard
|
||||
3. Verify the new version appears at: https://registry.terraform.io/providers/BerriAI/litellm/latest
|
||||
The mirror's `.github/` directory (the `Release` workflow) is the one thing the rsync preserves, so a change to the goreleaser *workflow* is a direct PR on the mirror; a change to `.goreleaser.yml` itself lands here like any other source change.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
@ -207,21 +129,15 @@ If this provider is published to the Terraform Registry:
|
|||
|
||||
### Tag Already Exists
|
||||
|
||||
**Error**: The publish workflow refuses to push because the tag already exists on the mirror
|
||||
**Error**: The publish job refuses to push because the tag already exists on the mirror
|
||||
|
||||
**Solution**: Tags are immutable by design. Re-run the workflow with a new patch version instead of deleting or moving an existing tag
|
||||
**Solution**: Tags are immutable by design and the version is the LiteLLM version, so this means the provider was already mirrored for this release. If the registry is missing the version, re-run the mirror's `Release` workflow for the existing tag rather than re-tagging
|
||||
|
||||
## Version Numbering
|
||||
|
||||
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html):
|
||||
The provider version is the LiteLLM version, verbatim: `X.Y.Z` for a stable release, `X.Y.Z-rc.N` for a release candidate and `X.Y.Z-dev.N` for a nightly. It says which proxy the provider shipped with and was audited against; it does not follow SemVer's break-signalling, so breaking changes are announced in `CHANGELOG.md` and the registry docs instead.
|
||||
|
||||
- **MAJOR** version (1.0.0): Incompatible API changes
|
||||
- **MINOR** version (0.1.0): New functionality in a backward-compatible manner
|
||||
- **PATCH** version (0.0.1): Backward-compatible bug fixes
|
||||
|
||||
For pre-1.0 releases:
|
||||
- Breaking changes may occur in minor versions
|
||||
- Patch versions should only contain bug fixes
|
||||
Versions `0.1.0` to `0.4.0` predate this and remain in the registry on their own line. A `~> 0.4` constraint never receives another release.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
|
|
@ -237,5 +153,4 @@ For pre-1.0 releases:
|
|||
- [Terraform Provider Publishing](https://www.terraform.io/docs/registry/providers/publishing.html)
|
||||
- [HashiCorp GPG Signing Requirements](https://www.terraform.io/docs/registry/providers/publishing.html#signing-releases)
|
||||
- [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets)
|
||||
- [Semantic Versioning](https://semver.org/)
|
||||
- [Keep a Changelog](https://keepachangelog.com/)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
{
|
||||
"TQ001": {
|
||||
"limit": 750
|
||||
"limit": 744
|
||||
},
|
||||
"TQ002": {
|
||||
"limit": 742
|
||||
},
|
||||
"TQ003": {
|
||||
"limit": 1078
|
||||
"limit": 1068
|
||||
},
|
||||
"TQ004": {
|
||||
"limit": 757
|
||||
"limit": 469
|
||||
},
|
||||
"TQ005": {
|
||||
"limit": 2810
|
||||
"limit": 2436
|
||||
},
|
||||
"TQ006": {
|
||||
"limit": 34
|
||||
|
|
|
|||
|
|
@ -77,13 +77,26 @@ Mark live tests with `@pytest.mark.e2e` (on the class or the module). Pure cover
|
|||
|
||||
The seam is `provider_edge.py`: `start_provider_edge` boots an in-process HTTP server (one shared instance per pytest process, `e2e_config.provider_edge_base` is the accessor) that mounts each supported provider under a path prefix (`EDGE_MOUNTS`: `/openai` -> `https://api.openai.com`, `/anthropic` -> `https://api.anthropic.com`). A test participates by registering its deployment with `api_base=provider_edge_base("openai")` plus the provider's path suffix; `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` is the reference. In live mode the accessor returns None and the deployment defaults to the real provider, so an edge-wired test runs in all three modes unchanged. Non-wired tests hit their providers live in every mode. The edge binds `E2E_PROVIDER_EDGE_BIND_HOST` (default 127.0.0.1) and advertises `E2E_PROVIDER_EDGE_ADVERTISE_HOST` in the api_base it hands out, for proxies running in containers
|
||||
|
||||
A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per provider call in call order (`0000-post-openai-v1-chat-completions.json`). Request headers are never stored (provider credentials never touch disk), non-JSON request bodies store a canonicalized sha256 digest instead of the bytes, and responses store status, filtered headers, and the verbatim body base64-encoded, which is part of why bundles are gitignored. `fixture_bundle.py` owns the format. Record serves the proxy the same filtered stored response replay will serve later, so the two modes are byte-identical from the proxy's side of the socket
|
||||
A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per provider call in call order (`0000-post-openai-v1-chat-completions.json`). Request headers are never stored (provider credentials never touch disk), non-JSON request bodies store a canonicalized sha256 digest instead of the bytes, `multipart/form-data` bodies store their ordinary fields plus a JSON list of the uploaded parts' `[field, filename, content-type]` triples and a digest of their content, so the per-request random boundary and the envelope never reach the key, and responses store status, filtered headers, and the verbatim body base64-encoded, which is part of why bundles are gitignored. `fixture_bundle.py` owns the format. Record serves the proxy the same filtered stored response replay will serve later, so the two modes are byte-identical from the proxy's side of the socket
|
||||
|
||||
Multipart identity is the fiddly corner, and the rules exist because each one had a collision behind it. A part counts as an upload when it carries a filename or declares its own content type, and everything else is an ordinary field. Field names get a `name[n]` suffix on repeats, with a literal `[` doubled first, so a form that repeats `purpose` never keys the same as one that literally sends `purpose[1]`. A field whose name reads as a credential is stored as `<secret>`, which stays key-preserving because the key is recomputed from the stored request rather than saved alongside it, so the live request carrying the real value still matches its redacted fixture. A field value that is not UTF-8 is stored as a base64 sha256 digest, base64 and not hex because the canonicalizer rewrites any 64-character hex run to `<sha256>` and would fold every binary value onto one key. The uploaded parts contribute a JSON list rather than a `field:filename` string, so a separator inside a filename cannot impersonate a field boundary, and their byte length is stored for a reader's benefit but deliberately left out of the key, since the canonicalizer absorbs timestamp and id drift inside a file that changes its length
|
||||
|
||||
Replay matches calls per test by canonical key: `fixture_canonical.py` canonicalizes the recorded request (volatile headers and credential fields out, unique markers, generated ids, uuids, and timestamps replaced with fixed placeholders, object keys sorted) and the key is the method, edge path, and a content hash, so identity survives re-records and machine changes while any real content drift comes back as an HTTP 599 naming the computed key, the closest recorded key with its file, and a content diff, and never falls through to a live call. Matching is order-independent across distinct keys (concurrent calls may interleave) and FIFO within one key (a retry loop replays its responses in recorded order); a passed test must also consume its whole recording, or teardown fails it naming a leftover key. Either way the fix is always to re-record with `E2E_FIXTURE_MODE=record`. Every rewrite rule lives in `fixture_canonical.py`, so a new volatile header, credential field name, or generated-id shape is one edit there. Record starts fresh every time: it wipes the previous bundle (refusing to wipe a directory that is not a bundle) and never reads it. A replay bundle whose manifest is older than seven days hard-fails at collection time naming the bundle's age, so replay can never certify against fixtures that have drifted more than a week from the live providers
|
||||
|
||||
A replayed response carries the recorded provider response id, and `LiteLLM_SpendLogs.request_id` (the table's primary key) is that id, so a replay against a database that still holds the record run's rows silently dedupes its spend inserts and any spend assertion goes red with zero matching rows and nothing in the proxy log. Run both modes with `E2E_RESET_SPEND_LOGS=1` (plus `DATABASE_URL` in the runner env) so each session truncates the table after itself, or replay against a fresh database, which is the CI shape
|
||||
|
||||
Current limits: streaming chunk fidelity is LIT-5742 (a streamed response records as one buffered body), CI wiring is LIT-5748, Bedrock cannot be mounted (SigV4 signs the Host header, so a rewritten api_base fails signature verification), multipart uploads have per-run random boundaries (the digest changes every run, so they always miss), and deployments baked into the proxy's config file cannot be edge-wired (only `/model/new` registrations can carry the edge api_base)
|
||||
The same id reuse reaches the managed-object tables. A replayed `/v1/files` or `/v1/batches` response carries the recorded provider object id, and `LiteLLM_ManagedObjectTable.model_object_id` is unique, so a unified batch create replayed against a database that still holds the record run's row fails on a Prisma unique-constraint violation, which surfaces as a 500, makes the router retry, and exhausts the recording. Replay the batches suite against a fresh database, or truncate `LiteLLM_ManagedObjectTable` and `LiteLLM_ManagedFileTable` before the run
|
||||
|
||||
Edge-wired today: `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` (the reference), `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the Anthropic deployments in `llm_translation/test_messages_e2e.py` except the streaming test, and the OpenAI batch deployment behind `batches/` (`capabilities.openai_batch_params`). The mount base is not the same for both providers: OpenAI deployments register `f"{base}/v1"`, Anthropic deployments register `base` on its own, because litellm's Anthropic handler appends `/v1/messages` to `api_base` itself where the OpenAI handler appends only `/chat/completions`. Recording one suite locally is two runs against a proxy you already have up:
|
||||
|
||||
```bash
|
||||
E2E_FIXTURE_MODE=record E2E_FIXTURE_DIR=/tmp/e2e-fixtures E2E_RESET_SPEND_LOGS=1 uv run pytest tests/e2e/llm_translation/test_chat_completions_contract_e2e.py
|
||||
E2E_FIXTURE_MODE=replay E2E_FIXTURE_DIR=/tmp/e2e-fixtures E2E_RESET_SPEND_LOGS=1 uv run pytest tests/e2e/llm_translation/test_chat_completions_contract_e2e.py
|
||||
```
|
||||
|
||||
Point the proxy at bogus provider credentials for the replay run and it still has to pass: that is the whole proof that nothing left the process. Bundles are never committed. `tests/e2e/.fixtures` is gitignored because a bundle holds verbatim provider response bodies and hard-fails after seven days, and publishing one for CI is LIT-5748
|
||||
|
||||
Current limits: streaming chunk fidelity is LIT-5742 (a streamed response records as one buffered body), CI wiring is LIT-5748, Bedrock cannot be mounted (SigV4 signs the Host header, so a rewritten api_base fails signature verification), deployments baked into the proxy's config file cannot be edge-wired (only `/model/new` registrations can carry the edge api_base), and a file upload routed by `custom_llm_provider` through the proxy's `files_settings` block never passes a deployment at all, so the batches `model_param` and `provider_fallback` scenarios keep uploading live in every mode
|
||||
|
||||
## Typing
|
||||
|
||||
|
|
|
|||
|
|
@ -57,13 +57,15 @@ Some suites need extra services the bare proxy does not start. The `logging/` OT
|
|||
Record/replay scopes to the proxy's provider-bound traffic only. In `E2E_FIXTURE_MODE=record` the harness boots a local provider-edge server, edge-wired tests register their deployments with an `api_base` pointing at it, and every provider call the proxy makes is forwarded verbatim and written to a fixture bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`). `E2E_FIXTURE_MODE=replay` runs the same tests against the same live proxy and database, but the edge answers the proxy's provider calls from the bundle instead of the provider, so the run makes zero provider calls and spends nothing while key auth, routing, cost calculation, and spend-log writes all still execute for real. Unset (or `live`) behaves exactly as before the knob existed. Both record and replay need the proxy up; only the provider is taken out of the loop
|
||||
|
||||
```bash
|
||||
E2E_FIXTURE_MODE=record uv run pytest tests/e2e/quota_management/spend_tracking/test_provider_edge_spend_e2e.py -v
|
||||
E2E_FIXTURE_MODE=replay uv run pytest tests/e2e/quota_management/spend_tracking/test_provider_edge_spend_e2e.py -v
|
||||
E2E_FIXTURE_MODE=record E2E_FIXTURE_DIR=/tmp/e2e-fixtures uv run pytest tests/e2e/quota_management/spend_tracking/test_provider_edge_spend_e2e.py -v
|
||||
E2E_FIXTURE_MODE=replay E2E_FIXTURE_DIR=/tmp/e2e-fixtures uv run pytest tests/e2e/quota_management/spend_tracking/test_provider_edge_spend_e2e.py -v
|
||||
```
|
||||
|
||||
Bundles stay local. `tests/e2e/.fixtures` is gitignored because a bundle holds verbatim provider response bodies and expires seven days after it was recorded, so record the suite you want before you replay it and never commit the result; publishing bundles for CI is LIT-5748
|
||||
|
||||
One sharp edge: a replayed response reuses the recorded provider response id, and that id is the primary key of `LiteLLM_SpendLogs`, so replaying against a database that still holds the record run's rows silently dedupes the spend writes and a spend assertion fails with zero rows. Run both commands above with `E2E_RESET_SPEND_LOGS=1` (and `DATABASE_URL` set in the pytest env) so each session truncates the spend log table after itself, or point replay at a fresh database
|
||||
|
||||
Replay answers any provider call that drifted from the recording with an HTTP 599 whose body names the computed and closest recorded keys, so the test fails loudly instead of silently going live, and a bundle older than seven days fails at collection time naming its age; either way the fix is to re-record. Only tests that register edge-wired deployments participate: everything else hits its provider live in every mode, so record exactly the suite you replay. If the proxy runs in a container, set `E2E_PROVIDER_EDGE_ADVERTISE_HOST` (e.g. `host.docker.internal`) so the api_base the proxy stores can reach the edge on the pytest host, and `E2E_PROVIDER_EDGE_BIND_HOST=0.0.0.0` so the edge accepts it. See `CLAUDE.md` in this directory for the bundle format, the edge design, and the current limits (streaming, Bedrock, multipart)
|
||||
Replay answers any provider call that drifted from the recording with an HTTP 599 whose body names the computed and closest recorded keys, so the test fails loudly instead of silently going live, and a bundle older than seven days fails at collection time naming its age; either way the fix is to re-record. Only tests that register edge-wired deployments participate: everything else hits its provider live in every mode, so record exactly the suite you replay. If the proxy runs in a container, set `E2E_PROVIDER_EDGE_ADVERTISE_HOST` (e.g. `host.docker.internal`) so the api_base the proxy stores can reach the edge on the pytest host, and `E2E_PROVIDER_EDGE_BIND_HOST=0.0.0.0` so the edge accepts it. The suites wired to the edge today are `quota_management/spend_tracking/test_provider_edge_spend_e2e.py`, `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the non-streaming Anthropic tests in `llm_translation/test_messages_e2e.py`, and the OpenAI batch deployment behind `batches/`. See `CLAUDE.md` in this directory for the bundle format, the edge design, and the current limits (streaming, Bedrock)
|
||||
|
||||
Tests marked `@pytest.mark.e2e` hard-fail when no proxy answers `/health/liveliness`, so a run that goes red with `No live proxy` at setup means the proxy isn't up; they never skip for a missing proxy, so an absent proxy can't be mistaken for a pass
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# Batches Test Coverage Matrix
|
||||
|
||||
Live e2e coverage of the Batches API over a real proxy, real provider keys, and
|
||||
real cost. Synchronous tier only: a batch's completion window is 24h, so these
|
||||
tests never wait for `completed`. They assert the proxy accepts, routes, retrieves,
|
||||
cancels, and lists a batch; everything created is deleted on teardown.
|
||||
real cost. Mostly synchronous tier: a batch's completion window is 24h, so the
|
||||
lifecycle matrix never waits for `completed`. It asserts the proxy accepts, routes,
|
||||
retrieves, cancels, and lists a batch; everything created is deleted on teardown.
|
||||
The exception is `TestBatchTerminalState`, which covers the completed state and
|
||||
cost write-back via a cross-run marker baton (design below).
|
||||
|
||||
## Provider x operation
|
||||
|
||||
|
|
@ -12,19 +14,26 @@ row per supported (provider, scenario) pair, so there are no skipped cells in th
|
|||
parametrized run. The batches suite never skips: missing provider creds or upstream
|
||||
failures are hard test failures (see `tests/e2e/CLAUDE.md`).
|
||||
|
||||
| Provider | create | retrieve | cancel | list | file backing |
|
||||
|-----------|--------|----------|--------|------|--------------|
|
||||
| OpenAI | yes | yes | yes | yes | OpenAI Files |
|
||||
| Azure | yes | yes | yes | yes | Azure Files |
|
||||
| Vertex AI | yes | yes | yes | yes | GCS (`gcs_bucket_name` / `GCS_BUCKET_NAME` on model) |
|
||||
| Bedrock | yes (unified only) | yes | no (limited upstream) | no | S3 (`s3_bucket_name` + `aws_*` + `AWS_BATCH_ROLE_ARN` on model) |
|
||||
| Provider | create | retrieve | cancel | list | content download | file backing |
|
||||
|-----------|--------|----------|--------|------|------------------|--------------|
|
||||
| OpenAI | yes | yes | yes | yes | yes (lifecycle + terminal output) | OpenAI Files |
|
||||
| Azure | yes | yes | yes | yes | yes (byte-verbatim) | Azure Files |
|
||||
| Vertex AI | yes | yes | yes | yes | yes (provider-transformed) | GCS (`gcs_bucket_name` / `GCS_BUCKET_NAME` on model) |
|
||||
| Bedrock | yes (unified only) | yes | no (limited upstream) | no | yes (provider-transformed) | S3 (`s3_bucket_name` + `aws_*` + `AWS_BATCH_ROLE_ARN` on model) |
|
||||
|
||||
Bedrock cancel is unreliable upstream and list is unsupported, so both are gated off
|
||||
(`can_cancel=False`, `can_list=False`) when that provider is enabled in the matrix.
|
||||
(`can_cancel=False`, `can_list=False`) when that provider is enabled in the matrix;
|
||||
flipping those gates is tracked in LIT-4774 and deliberately not part of this suite.
|
||||
Bedrock file upload requires a model on the request (`encoded` / `unified` scenarios only);
|
||||
`model_param` and `provider_fallback` are omitted because `POST /bedrock/v1/files` has no
|
||||
model-less passthrough path.
|
||||
|
||||
`GET /v1/files/{id}/content` is exercised for the unified upload path per backend in
|
||||
`test_unified_file_content_downloads`. Azure stores the JSONL verbatim, so its download
|
||||
is asserted byte-equal to the upload. Vertex (GCS) and Bedrock (S3) transform lines at
|
||||
upload time, so those assert a 200 with non-empty parseable JSON lines instead. Gemini
|
||||
(non-Vertex) raises `NotImplementedError` for file content and has no cell here.
|
||||
|
||||
## Routing scenarios (per `litellm/proxy/batches_endpoints/endpoints.py`)
|
||||
|
||||
Each create-capable provider runs all four. The test asserts the returned file id
|
||||
|
|
@ -71,11 +80,59 @@ File delete asserts `object=="file"` and `deleted==True`.
|
|||
| `batch_client.py` | typed file upload/download + batch create/retrieve/cancel/list/delete over the shared ProxyClient; runtime batch model registration via /model/new; denial helpers |
|
||||
| `capabilities.py` | the provider x scenario matrix + per-provider /model/new params + id-shape classifiers + per-provider raw-id assertion |
|
||||
| `conftest.py` | session-scoped batch deployment registration and teardown |
|
||||
| `test_batches_e2e.py` | parametrized lifecycle with per-endpoint output assertions, file upload/delete outputs, key-model-access denial |
|
||||
| `test_batches_e2e.py` | parametrized lifecycle with per-endpoint output assertions, file upload/delete outputs, key-model-access denial, per-backend content download, failure paths, second-hop routing, terminal state + cost |
|
||||
|
||||
## Failure paths
|
||||
|
||||
`TestBatchFailurePaths` pins the customer-facing error contracts. A malformed input
|
||||
file is a 400 at upload naming the bad content. A JSONL line whose url contradicts
|
||||
the batch endpoint passes create (providers validate asynchronously) and drives the
|
||||
batch to `failed` with structured `errors.data` (code/line/message), a null
|
||||
`output_file_id`, and a $0 spend row keyed `{batch_id}_batch_cost` (LIT-4852: a
|
||||
failed batch books $0 instead of crashing cost tracking). Cancelling that failed
|
||||
batch is a 409 naming the terminal status. A file id encoded for one deployment wins
|
||||
over a conflicting `model` param on create: the batch routes and re-encodes by the
|
||||
file's embedded model (foreign-id precedence).
|
||||
|
||||
## Second hop (two chained gateways)
|
||||
|
||||
`TestBatchSecondHop` registers a `litellm_proxy/<inner model>` deployment pointing at
|
||||
the proxy's own base URL with a freshly minted virtual key, so unified upload and
|
||||
create traverse gateway -> gateway -> OpenAI (LIT-5347, PR #36240). The pin:
|
||||
`target_model_names` is rewritten to the inner deployment on the second hop and the
|
||||
nested managed ids round-trip retrieve. This self-chaining only needs the proxy to
|
||||
reach its own `PROXY_BASE_URL`, which holds both locally and on the e2e stage.
|
||||
|
||||
## Terminal state + cost write-back (cross-run marker baton)
|
||||
|
||||
The 24h completion window rules out submit-and-wait inside one run, so
|
||||
`TestBatchTerminalState` amortizes across runs. Each run submits a 1-line marker
|
||||
batch (stable metadata key/value plus a per-run field) and deliberately never
|
||||
cancels or deletes it or its input file: the marker is the baton the next run picks
|
||||
up (OpenAI files expire on their own after ~30 days). Polling is list-only, up to 5
|
||||
minutes, because retrieving a non-terminal batch books a $0 spend row whose
|
||||
request_id then blocks the later real-cost row (`skip_duplicates`); the single
|
||||
retrieve happens only once a completed marker exists. The assertion target is the
|
||||
newest completed marker from ANY run: run-scoped deployment names mean the list
|
||||
re-encodes prior-run batches under new encoded ids, so their spend keys are fresh
|
||||
and a prior-run marker is billable by this run. On the 6h stage cadence the full
|
||||
assertions are therefore deterministic from run 2 onward. On a cold start (no
|
||||
completed marker within the poll budget) the test passes on the submission
|
||||
assertions alone: a documented vacuous pass, not a skip. Markers aged past the 24h
|
||||
window (25h-73h band, within the newest 100-item list page) must be terminal.
|
||||
|
||||
The cost assertion is the LIT-5730 headline: retrieving a completed model-encoded
|
||||
batch must write a positive spend row with call_type `aretrieve_batch` and token
|
||||
usage. Before the fix in `litellm/batches/batch_utils.py`, the retrieve endpoint
|
||||
re-encoded the response's `output_file_id` in place before the queued logging
|
||||
worker ran, the worker sent that encoded id to OpenAI, got a 404, and the spend row
|
||||
never landed.
|
||||
|
||||
## Out of scope (intentionally)
|
||||
|
||||
Driving a batch to `completed`, cost tracking on completion, and the DB write-back
|
||||
are not covered here; the 24h window makes them unfit for a synchronous gate. That
|
||||
logic belongs in a DI-stubbed proxy integration test under `tests/test_litellm/proxy/`
|
||||
where the provider client is injected to return `completed` deterministically.
|
||||
Unified (managed) batch cost is owned by the hourly `CheckBatchCost` poller, and a
|
||||
terminal DB status short-circuits retrieve for those ids, so the terminal-state cell
|
||||
uses the encoded path; poller timing does not fit an e2e gate and belongs in a
|
||||
DI-stubbed proxy integration test under `tests/test_litellm/proxy/`. Bedrock
|
||||
cancel/list stay gated pending LIT-4774. Gemini (non-Vertex) file content raises
|
||||
`NotImplementedError` upstream and is not a coverage cell.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,17 @@ class FileList(BaseModel):
|
|||
has_more: bool | None = None
|
||||
|
||||
|
||||
class BatchErrorItem(BaseModel):
|
||||
code: str | None = None
|
||||
line: int | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class BatchErrorList(BaseModel):
|
||||
object: str | None = None
|
||||
data: list[BatchErrorItem] = []
|
||||
|
||||
|
||||
class BatchObject(BaseModel):
|
||||
id: str
|
||||
object: str | None = None
|
||||
|
|
@ -58,6 +69,9 @@ class BatchObject(BaseModel):
|
|||
endpoint: str | None = None
|
||||
input_file_id: str | None = None
|
||||
output_file_id: str | None = None
|
||||
error_file_id: str | None = None
|
||||
errors: BatchErrorList | None = None
|
||||
metadata: dict[str, str] | None = None
|
||||
completion_window: str | None = None
|
||||
created_at: int | None = None
|
||||
model: str | None = None
|
||||
|
|
@ -79,12 +93,18 @@ class BatchCreateBody(BaseModel):
|
|||
endpoint: str = "/v1/chat/completions"
|
||||
completion_window: str = "24h"
|
||||
model: str | None = None
|
||||
metadata: dict[str, str] | None = None
|
||||
|
||||
|
||||
class ModelQuery(BaseModel):
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class BatchListQuery(BaseModel):
|
||||
model: str | None = None
|
||||
limit: int | None = None
|
||||
|
||||
|
||||
def is_model_access_denied(resp: StreamingResponse) -> bool:
|
||||
"""True if the proxy rejected the call because the key may not access the model."""
|
||||
return resp.status_code == 403 and "key_model_access_denied" in resp.body
|
||||
|
|
@ -175,12 +195,17 @@ class BatchClient:
|
|||
)
|
||||
|
||||
def list_batches(
|
||||
self, *, key: str, provider: str | None = None
|
||||
self,
|
||||
*,
|
||||
key: str,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> Result[BatchList]:
|
||||
return self.proxy.transport.get(
|
||||
_batches_path(provider),
|
||||
headers=self.proxy.transport.bearer(key),
|
||||
params=NoBody(),
|
||||
params=BatchListQuery(model=model, limit=limit),
|
||||
response_type=BatchList,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ from __future__ import annotations
|
|||
import base64
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
from typing import Final, Literal
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_config import provider_edge_base, unique_marker
|
||||
from models import LiteLLMParamsBody
|
||||
|
||||
_BATCH_RUN = unique_marker()
|
||||
|
|
@ -17,6 +17,21 @@ def batch_model_name(base: str) -> str:
|
|||
return f"{base}-{_BATCH_RUN}"
|
||||
|
||||
|
||||
OPENAI_BATCH_BACKEND: Final = "gpt-4o-mini"
|
||||
|
||||
|
||||
def openai_batch_params() -> LiteLLMParamsBody:
|
||||
"""The OpenAI batch deployment, wired through the record/replay edge when a fixture
|
||||
mode is active and straight at OpenAI otherwise (LIT-5974). Azure, Vertex, and
|
||||
Bedrock stay live: none of them has an edge mount."""
|
||||
base = provider_edge_base("openai")
|
||||
return LiteLLMParamsBody(
|
||||
model=f"openai/{OPENAI_BATCH_BACKEND}",
|
||||
api_key="os.environ/OPENAI_API_KEY",
|
||||
api_base=None if base is None else f"{base}/v1",
|
||||
)
|
||||
|
||||
|
||||
def _env_ref(*names: str) -> str:
|
||||
for name in names:
|
||||
value = os.environ.get(name)
|
||||
|
|
@ -47,10 +62,7 @@ class Provider:
|
|||
def litellm_params(self) -> LiteLLMParamsBody:
|
||||
match self.name:
|
||||
case "openai":
|
||||
return LiteLLMParamsBody(
|
||||
model="openai/gpt-4o-mini",
|
||||
api_key="os.environ/OPENAI_API_KEY",
|
||||
)
|
||||
return openai_batch_params()
|
||||
case "azure":
|
||||
return LiteLLMParamsBody(
|
||||
model="azure/gpt-5.4-mini-batch",
|
||||
|
|
@ -107,7 +119,11 @@ class Capability:
|
|||
|
||||
PROVIDERS: tuple[Provider, ...] = (
|
||||
Provider(
|
||||
"openai", batch_model_name("openai-batch"), "gpt-4o-mini", can_cancel=True, can_list=True
|
||||
"openai",
|
||||
batch_model_name("openai-batch"),
|
||||
OPENAI_BATCH_BACKEND,
|
||||
can_cancel=True,
|
||||
can_list=True,
|
||||
),
|
||||
Provider(
|
||||
"azure",
|
||||
|
|
@ -210,6 +226,16 @@ def is_model_encoded_id(id_str: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def decoded_model_from_id(id_str: str) -> str | None:
|
||||
"""Deployment name embedded in a model-encoded file/batch id, or None."""
|
||||
for prefix in ("file-", "batch_"):
|
||||
if id_str.startswith(prefix):
|
||||
decoded = _b64_decode(id_str[len(prefix) :])
|
||||
if decoded.startswith("litellm:") and ";model," in decoded:
|
||||
return decoded.split(";model,", 1)[1].split(";")[0]
|
||||
return None
|
||||
|
||||
|
||||
def matches_id_shape(shape: IdShape, id_str: str) -> bool:
|
||||
if shape == "managed":
|
||||
return is_managed_id(id_str)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"""Live e2e for the Batches API across every provider LiteLLM supports.
|
||||
|
||||
Synchronous tier only: a batch's completion window is 24h, so these never wait for
|
||||
"completed". Each case uploads a tiny JSONL, creates the batch through one of the
|
||||
four routing scenarios, asserts it was accepted (non-terminal status) and routed to
|
||||
the right provider, then retrieves / cancels / lists where the provider supports it.
|
||||
Everything created is deleted on teardown. Completion + cost tracking are out of
|
||||
scope here (see COVERAGE.md).
|
||||
Mostly synchronous tier: a batch's completion window is 24h, so the lifecycle
|
||||
matrix never waits for "completed". Each case uploads a tiny JSONL, creates the
|
||||
batch through one of the four routing scenarios, asserts it was accepted
|
||||
(non-terminal status) and routed to the right provider, then retrieves / cancels /
|
||||
lists where the provider supports it. Everything created is deleted on teardown.
|
||||
The exception is TestBatchTerminalState, which carries completed-state + cost
|
||||
write-back coverage via a cross-run marker baton (design in COVERAGE.md).
|
||||
|
||||
Routing signal: for provider_fallback the raw batch id discriminates the provider;
|
||||
for the encoded/unified/model_param scenarios the proxy re-encodes the id, so the
|
||||
|
|
@ -23,8 +24,9 @@ from datetime import datetime, timedelta, timezone
|
|||
from typing import Callable
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_config import PROXY_BASE_URL, unique_marker
|
||||
|
||||
from batch_client import (
|
||||
UPLOAD_FILENAME,
|
||||
|
|
@ -40,12 +42,17 @@ from capabilities import (
|
|||
BATCH_ID_SHAPE,
|
||||
CAPABILITIES,
|
||||
FILE_ID_SHAPE,
|
||||
OPENAI_BATCH_BACKEND,
|
||||
OPENAI_BATCH_MODEL,
|
||||
PROVIDERS,
|
||||
Capability,
|
||||
Provider,
|
||||
batch_model_name,
|
||||
coverage_cells_for_lifecycle,
|
||||
decoded_model_from_id,
|
||||
is_managed_id,
|
||||
matches_id_shape,
|
||||
openai_batch_params,
|
||||
raw_id_matches_provider,
|
||||
)
|
||||
from e2e_http import (
|
||||
|
|
@ -474,11 +481,22 @@ def test_rate_limited_batch_create_leaves_no_unattributed_spend_row(
|
|||
)
|
||||
|
||||
|
||||
OPENAI_FILE_CONTENT_BACKEND = "gpt-4o-mini"
|
||||
FILE_CONTENT_CELLS = {
|
||||
"azure": "llm.files.azure_openai.content.nonstream.works",
|
||||
"vertex_ai": "llm.files.vertex.content.nonstream.works",
|
||||
"bedrock": "llm.files.bedrock.content.nonstream.works",
|
||||
}
|
||||
BYTE_FIDELITY_CONTENT_PROVIDERS = frozenset({"azure"})
|
||||
|
||||
|
||||
class TestBatchFileContent:
|
||||
"""GET /v1/files/{id}/content returns the uploaded batch JSONL bytes."""
|
||||
"""GET /v1/files/{id}/content returns the uploaded batch JSONL bytes.
|
||||
|
||||
Azure stores the upload verbatim, so its download is asserted byte-equal.
|
||||
Vertex (GCS) and Bedrock (S3) transform each JSONL line into the provider's
|
||||
request format at upload time, so their downloads assert 200 plus non-empty
|
||||
parseable JSON lines instead of byte equality.
|
||||
"""
|
||||
|
||||
@pytest.mark.covers(
|
||||
"llm.files.openai.content.nonstream.works",
|
||||
|
|
@ -488,17 +506,11 @@ class TestBatchFileContent:
|
|||
self, client: BatchClient, resources: ResourceManager
|
||||
) -> None:
|
||||
proxy_name = f"e2e-file-content-{unique_marker()}"
|
||||
model_id = client.create_model(
|
||||
proxy_name,
|
||||
LiteLLMParamsBody(
|
||||
model=f"openai/{OPENAI_FILE_CONTENT_BACKEND}",
|
||||
api_key="os.environ/OPENAI_API_KEY",
|
||||
),
|
||||
)
|
||||
model_id = client.create_model(proxy_name, openai_batch_params())
|
||||
resources.defer(lambda: client.delete_model(model_id))
|
||||
key = resources.key()
|
||||
|
||||
payload = render_jsonl(OPENAI_FILE_CONTENT_BACKEND)
|
||||
payload = render_jsonl(OPENAI_BATCH_BACKEND)
|
||||
file = unwrap(
|
||||
client.upload_file(
|
||||
content=payload,
|
||||
|
|
@ -522,6 +534,62 @@ class TestBatchFileContent:
|
|||
"downloaded file content must match the uploaded JSONL bytes"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider",
|
||||
[
|
||||
pytest.param(
|
||||
p,
|
||||
id=p.name,
|
||||
marks=pytest.mark.covers(
|
||||
FILE_CONTENT_CELLS[p.name], exercised_on=["files"]
|
||||
),
|
||||
)
|
||||
for p in PROVIDERS
|
||||
if p.name in FILE_CONTENT_CELLS
|
||||
],
|
||||
)
|
||||
def test_unified_file_content_downloads(
|
||||
self,
|
||||
provider: Provider,
|
||||
client: BatchClient,
|
||||
resources: ResourceManager,
|
||||
batch_deployments: None,
|
||||
) -> None:
|
||||
key = resources.key()
|
||||
payload = render_jsonl(provider.raw_model)
|
||||
file = unwrap(
|
||||
client.upload_file(
|
||||
content=payload,
|
||||
form=FileUploadForm(purpose="batch", target_model_names=provider.model),
|
||||
key=key,
|
||||
)
|
||||
)
|
||||
resources.defer(quietly(lambda: client.delete_file(file.id, key=key)))
|
||||
assert_file_object(file, provider=provider.name)
|
||||
assert is_managed_id(file.id), (
|
||||
f"{provider.name}: unified upload must return a managed file id, got {file.id!r}"
|
||||
)
|
||||
|
||||
downloaded = client.proxy.transport.download(
|
||||
f"/v1/files/{file.id}/content",
|
||||
headers=client.proxy.transport.bearer(key),
|
||||
)
|
||||
assert downloaded.status_code == 200, (
|
||||
f"{provider.name}: file content must be 200, "
|
||||
f"got {downloaded.status_code}: {downloaded.body[:300]}"
|
||||
)
|
||||
body = downloaded.body.strip()
|
||||
assert body, f"{provider.name}: file content download returned an empty body"
|
||||
if provider.name in BYTE_FIDELITY_CONTENT_PROVIDERS:
|
||||
assert body == payload.decode().strip(), (
|
||||
f"{provider.name}: downloaded content must match the uploaded JSONL bytes"
|
||||
)
|
||||
else:
|
||||
for line in body.splitlines():
|
||||
assert json.loads(line), (
|
||||
f"{provider.name}: content line is not JSON: {line[:200]}"
|
||||
)
|
||||
|
||||
|
||||
class TestOpenAIFiles:
|
||||
"""GET /v1/files (list) and GET /v1/files/{id} (retrieve) over the OpenAI route.
|
||||
|
|
@ -1045,3 +1113,384 @@ class TestHostedVllmBatch:
|
|||
f"hosted_vllm batch has non-transitional status {batch.status!r}"
|
||||
)
|
||||
assert_batch_object(batch)
|
||||
|
||||
|
||||
BATCH_TERMINAL_STATUSES = frozenset({"completed", "failed", "expired", "cancelled"})
|
||||
FAILED_BATCH_POLL_SECONDS = 120.0
|
||||
FAILED_BATCH_POLL_INTERVAL_SECONDS = 5.0
|
||||
|
||||
AZURE_BATCH_RAW_MODEL = next(p.raw_model for p in PROVIDERS if p.name == "azure")
|
||||
|
||||
|
||||
def _mismatched_endpoint_jsonl(model: str) -> bytes:
|
||||
line = {
|
||||
"custom_id": "req-1",
|
||||
"method": "POST",
|
||||
"url": "/v1/embeddings",
|
||||
"body": {"model": model, "input": "ping"},
|
||||
}
|
||||
return (json.dumps(line) + "\n").encode()
|
||||
|
||||
|
||||
def _poll_until_terminal(client: BatchClient, batch_id: str, key: str) -> BatchObject:
|
||||
deadline = time.monotonic() + FAILED_BATCH_POLL_SECONDS
|
||||
fetched = retrieve_batch(client, batch_id, key=key, provider=None)
|
||||
while fetched.status not in BATCH_TERMINAL_STATUSES and time.monotonic() < deadline:
|
||||
time.sleep(FAILED_BATCH_POLL_INTERVAL_SECONDS)
|
||||
fetched = retrieve_batch(client, batch_id, key=key, provider=None)
|
||||
return fetched
|
||||
|
||||
|
||||
class TestBatchFailurePaths:
|
||||
"""Customer-facing failure contracts for /v1/batches.
|
||||
|
||||
A malformed input file is rejected at upload with a 400 naming the bad
|
||||
content. A JSONL line whose url contradicts the batch endpoint is accepted
|
||||
at create (providers validate asynchronously) and drives the batch to
|
||||
"failed" with structured per-line errors, a null output_file_id, and a
|
||||
zero-cost spend row (LIT-4852: a failed batch must book $0, not crash cost
|
||||
tracking). Cancelling that already-failed batch returns a 409 naming the
|
||||
terminal status. A file id encoded for one deployment wins over a
|
||||
conflicting model param on create: the batch routes (and re-encodes) by the
|
||||
file's embedded model, pinning that precedence.
|
||||
"""
|
||||
|
||||
@pytest.mark.covers(
|
||||
"llm.batches.openai.malformed_jsonl.nonstream.works",
|
||||
exercised_on=["files"],
|
||||
)
|
||||
def test_malformed_jsonl_upload_rejected(
|
||||
self, client: BatchClient, resources: ResourceManager, batch_deployments: None
|
||||
) -> None:
|
||||
result = client.upload_file(
|
||||
content=b"this is not json\n",
|
||||
form=FileUploadForm(purpose="batch"),
|
||||
model=OPENAI_BATCH_MODEL,
|
||||
key=resources.key(),
|
||||
)
|
||||
match result:
|
||||
case UnknownApiError(status_code=400, body=body):
|
||||
assert "json" in body.lower(), (
|
||||
f"400 must name the malformed JSONL so users can fix the file, got: {body[:300]}"
|
||||
)
|
||||
case _:
|
||||
pytest.fail(f"malformed JSONL upload must be rejected with a 400, got: {result}")
|
||||
|
||||
@pytest.mark.covers(
|
||||
"llm.batches.openai.jsonl_endpoint_mismatch.nonstream.works",
|
||||
"llm.batches.openai.cancel_terminal.nonstream.works",
|
||||
exercised_on=["batches", "files"],
|
||||
)
|
||||
def test_endpoint_mismatch_fails_batch_and_cancel_conflicts(
|
||||
self, client: BatchClient, resources: ResourceManager, batch_deployments: None
|
||||
) -> None:
|
||||
key = resources.key()
|
||||
file = unwrap(
|
||||
client.upload_file(
|
||||
content=_mismatched_endpoint_jsonl("gpt-4o-mini"),
|
||||
form=FileUploadForm(purpose="batch"),
|
||||
model=OPENAI_BATCH_MODEL,
|
||||
key=key,
|
||||
)
|
||||
)
|
||||
resources.defer(quietly(lambda: client.delete_file(file.id, key=key)))
|
||||
|
||||
created = client.create_batch(body=BatchCreateBody(input_file_id=file.id), key=key)
|
||||
require_successful_call(created)
|
||||
batch = BatchObject.model_validate_json(created.body)
|
||||
|
||||
fetched = _poll_until_terminal(client, batch.id, key)
|
||||
assert fetched.status == "failed", (
|
||||
f"endpoint-mismatched batch must fail, got {fetched.status!r}"
|
||||
)
|
||||
assert fetched.output_file_id is None, (
|
||||
f"failed batch must have no output file, got {fetched.output_file_id!r}"
|
||||
)
|
||||
assert fetched.errors is not None and fetched.errors.data, (
|
||||
"failed batch must surface structured errors so users can fix the JSONL"
|
||||
)
|
||||
first_error = fetched.errors.data[0]
|
||||
assert first_error.message, "batch error item has no message"
|
||||
assert first_error.code, "batch error item has no code"
|
||||
|
||||
rows = client.proxy.poll_logs_for_request_id(f"{fetched.id}_batch_cost")
|
||||
assert rows, (
|
||||
f"failed batch {fetched.id} wrote no spend row; retrieve must book $0 (LIT-4852)"
|
||||
)
|
||||
assert all((row.spend or 0) == 0 for row in rows), (
|
||||
f"failed batch must cost $0, got {[(r.request_id, r.spend) for r in rows]}"
|
||||
)
|
||||
assert rows[0].call_type == "aretrieve_batch", (
|
||||
f"batch cost row call_type={rows[0].call_type!r}"
|
||||
)
|
||||
|
||||
conflict = client.cancel_batch(batch.id, key=key)
|
||||
match conflict:
|
||||
case UnknownApiError(status_code=409, body=body):
|
||||
assert "failed" in body.lower(), (
|
||||
f"409 must name the terminal status blocking the cancel, got: {body[:300]}"
|
||||
)
|
||||
case _:
|
||||
pytest.fail(f"cancel of a failed batch must return a 409 conflict, got: {conflict}")
|
||||
|
||||
@pytest.mark.covers(
|
||||
"llm.batches.openai.foreign_file_id.nonstream.works",
|
||||
exercised_on=["batches", "files"],
|
||||
)
|
||||
def test_foreign_encoded_file_id_routes_by_file_model(
|
||||
self, client: BatchClient, resources: ResourceManager, batch_deployments: None
|
||||
) -> None:
|
||||
key = resources.key()
|
||||
file = unwrap(
|
||||
client.upload_file(
|
||||
content=render_jsonl(AZURE_BATCH_RAW_MODEL),
|
||||
form=FileUploadForm(purpose="batch"),
|
||||
model=AZURE_BATCH_MODEL,
|
||||
key=key,
|
||||
)
|
||||
)
|
||||
resources.defer(quietly(lambda: client.delete_file(file.id, key=key)))
|
||||
assert decoded_model_from_id(file.id) == AZURE_BATCH_MODEL, (
|
||||
f"upload did not encode the azure deployment into the file id: {file.id!r}"
|
||||
)
|
||||
|
||||
created = client.create_batch(
|
||||
body=BatchCreateBody(input_file_id=file.id, model=OPENAI_BATCH_MODEL), key=key
|
||||
)
|
||||
require_successful_call(created)
|
||||
batch = BatchObject.model_validate_json(created.body)
|
||||
resources.defer(quietly(lambda: client.cancel_batch(batch.id, key=key)))
|
||||
|
||||
assert decoded_model_from_id(batch.id) == AZURE_BATCH_MODEL, (
|
||||
"create with a foreign encoded file id must route by the file's embedded model, "
|
||||
f"but the batch id encodes {decoded_model_from_id(batch.id)!r} "
|
||||
f"(model param was {OPENAI_BATCH_MODEL!r})"
|
||||
)
|
||||
fetched = retrieve_batch(client, batch.id, key=key, provider=None)
|
||||
assert fetched.id == batch.id
|
||||
assert fetched.status, "retrieved foreign-file batch has no status"
|
||||
|
||||
|
||||
class TestBatchSecondHop:
|
||||
"""Two-proxy batch routing: a litellm_proxy deployment chained to the gateway
|
||||
itself (LIT-5347, PR #36240).
|
||||
|
||||
The hop deployment's litellm_params point litellm_proxy/<inner model> at this
|
||||
gateway's own base URL with a freshly minted virtual key, so the unified
|
||||
upload and batch create traverse gateway -> gateway -> OpenAI. The regression
|
||||
this pins: target_model_names must be rewritten to the inner deployment on
|
||||
the second hop and the nested managed ids must round-trip retrieve.
|
||||
"""
|
||||
|
||||
@pytest.mark.covers(
|
||||
"llm.batches.openai.second_hop.nonstream.works",
|
||||
exercised_on=["batches", "files"],
|
||||
)
|
||||
def test_unified_create_and_retrieve_via_chained_gateway(
|
||||
self, client: BatchClient, resources: ResourceManager, batch_deployments: None
|
||||
) -> None:
|
||||
key = resources.key()
|
||||
hop_name = batch_model_name("openai-batch-hop")
|
||||
model_id = client.create_model(
|
||||
hop_name,
|
||||
LiteLLMParamsBody(
|
||||
model=f"litellm_proxy/{OPENAI_BATCH_MODEL}",
|
||||
api_base=PROXY_BASE_URL,
|
||||
api_key=key,
|
||||
),
|
||||
)
|
||||
resources.defer(lambda: client.delete_model(model_id))
|
||||
|
||||
file = unwrap(
|
||||
client.upload_file(
|
||||
content=render_jsonl("gpt-4o-mini"),
|
||||
form=FileUploadForm(purpose="batch", target_model_names=hop_name),
|
||||
key=key,
|
||||
)
|
||||
)
|
||||
resources.defer(quietly(lambda: client.delete_file(file.id, key=key)))
|
||||
assert is_managed_id(file.id), (
|
||||
f"second-hop unified upload must return a managed file id, got {file.id!r}"
|
||||
)
|
||||
|
||||
created = client.create_batch(body=BatchCreateBody(input_file_id=file.id), key=key)
|
||||
require_successful_call(created)
|
||||
batch = BatchObject.model_validate_json(created.body)
|
||||
resources.defer(quietly(lambda: client.cancel_batch(batch.id, key=key)))
|
||||
|
||||
assert is_managed_id(batch.id), (
|
||||
f"second-hop create must return a managed batch id, got {batch.id!r}"
|
||||
)
|
||||
assert batch.status in CREATED_BATCH_STATUSES, (
|
||||
f"second-hop batch has non-transitional status {batch.status!r}"
|
||||
)
|
||||
assert_batch_object(batch)
|
||||
|
||||
fetched = retrieve_batch(client, batch.id, key=key, provider=None)
|
||||
assert fetched.id == batch.id
|
||||
assert fetched.status, "second-hop retrieve returned no status"
|
||||
|
||||
|
||||
class BatchOutputBody(BaseModel):
|
||||
choices: list[object] = []
|
||||
|
||||
|
||||
class BatchOutputResponse(BaseModel):
|
||||
status_code: int | None = None
|
||||
body: BatchOutputBody | None = None
|
||||
|
||||
|
||||
class BatchOutputLine(BaseModel):
|
||||
response: BatchOutputResponse
|
||||
|
||||
|
||||
TERMINAL_MARKER_KEY = "litellm_e2e_suite"
|
||||
TERMINAL_MARKER_VALUE = "batches-terminal-baton"
|
||||
TERMINAL_POLL_SECONDS = 300.0
|
||||
TERMINAL_POLL_INTERVAL_SECONDS = 10.0
|
||||
TERMINAL_LIST_LIMIT = 100
|
||||
TERMINAL_BAND_MIN_AGE_SECONDS = 25 * 3600
|
||||
TERMINAL_BAND_MAX_AGE_SECONDS = 73 * 3600
|
||||
|
||||
|
||||
def _marker_batches(client: BatchClient, key: str) -> list[BatchObject]:
|
||||
listed = unwrap(
|
||||
client.list_batches(key=key, model=OPENAI_BATCH_MODEL, limit=TERMINAL_LIST_LIMIT)
|
||||
)
|
||||
return [
|
||||
b
|
||||
for b in listed.data
|
||||
if (b.metadata or {}).get(TERMINAL_MARKER_KEY) == TERMINAL_MARKER_VALUE
|
||||
]
|
||||
|
||||
|
||||
def _await_completed_marker(
|
||||
client: BatchClient, key: str
|
||||
) -> tuple[BatchObject | None, list[BatchObject]]:
|
||||
deadline = time.monotonic() + TERMINAL_POLL_SECONDS
|
||||
while True:
|
||||
markers = _marker_batches(client, key)
|
||||
completed = max(
|
||||
(b for b in markers if b.status == "completed"),
|
||||
key=lambda b: b.created_at or 0,
|
||||
default=None,
|
||||
)
|
||||
if completed is not None or time.monotonic() >= deadline:
|
||||
return completed, markers
|
||||
time.sleep(TERMINAL_POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def _assert_aged_markers_terminal(markers: list[BatchObject]) -> None:
|
||||
now = time.time()
|
||||
stuck = [
|
||||
b
|
||||
for b in markers
|
||||
if b.created_at is not None
|
||||
and TERMINAL_BAND_MIN_AGE_SECONDS <= now - b.created_at <= TERMINAL_BAND_MAX_AGE_SECONDS
|
||||
and b.status not in BATCH_TERMINAL_STATUSES
|
||||
]
|
||||
assert not stuck, (
|
||||
"marker batches past their 24h completion window must be terminal; stuck: "
|
||||
f"{[(b.id, b.status, b.created_at) for b in stuck]}"
|
||||
)
|
||||
|
||||
|
||||
class TestBatchTerminalState:
|
||||
"""Terminal state + cost write-back via a cross-run marker baton.
|
||||
|
||||
Each run submits a 1-line marker batch (stable metadata key/value plus a
|
||||
per-run field) and never cancels or deletes it: the marker is the baton the
|
||||
next run picks up. Polling is list-only for up to 5 minutes because a
|
||||
retrieve of a non-terminal batch books a $0 spend row whose request_id then
|
||||
blocks the real-cost row (skip_duplicates); the single retrieve happens only
|
||||
once a completed marker exists. The assertion target is the newest completed
|
||||
marker from ANY run, so on the 6h stage cadence the full assertions are
|
||||
deterministic from run 2 onward. On a cold start (no marker has ever
|
||||
completed within the poll budget) the test passes on the submission
|
||||
assertions alone: that is a documented vacuous pass, not a skip, and this
|
||||
run's marker becomes the next run's target. Markers aged past OpenAI's 24h
|
||||
completion window (25h-73h band, within the newest list page) must be
|
||||
terminal. The cost assertion is the LIT-5730 headline: retrieving a
|
||||
completed model-encoded batch must write a positive spend row keyed
|
||||
{batch_id}_batch_cost; before the fix the logging worker fetched the
|
||||
re-encoded output_file_id, 404d, and the row never landed.
|
||||
"""
|
||||
|
||||
@pytest.mark.covers(
|
||||
"llm.batches.openai.terminal_state.nonstream.works",
|
||||
"llm.batches.openai.terminal_state.nonstream.cost_logged",
|
||||
exercised_on=["batches", "files"],
|
||||
)
|
||||
def test_completed_batch_downloads_output_and_books_cost(
|
||||
self, client: BatchClient, resources: ResourceManager, batch_deployments: None
|
||||
) -> None:
|
||||
key = resources.key()
|
||||
file = unwrap(
|
||||
client.upload_file(
|
||||
content=render_jsonl("gpt-4o-mini"),
|
||||
form=FileUploadForm(purpose="batch"),
|
||||
model=OPENAI_BATCH_MODEL,
|
||||
key=key,
|
||||
)
|
||||
)
|
||||
created = client.create_batch(
|
||||
body=BatchCreateBody(
|
||||
input_file_id=file.id,
|
||||
metadata={
|
||||
TERMINAL_MARKER_KEY: TERMINAL_MARKER_VALUE,
|
||||
"run": unique_marker(),
|
||||
},
|
||||
),
|
||||
key=key,
|
||||
)
|
||||
require_successful_call(created)
|
||||
submitted = BatchObject.model_validate_json(created.body)
|
||||
assert submitted.status in CREATED_BATCH_STATUSES, (
|
||||
f"marker batch has non-transitional status {submitted.status!r}"
|
||||
)
|
||||
assert (submitted.metadata or {}).get(TERMINAL_MARKER_KEY) == TERMINAL_MARKER_VALUE, (
|
||||
f"create dropped the marker metadata: {submitted.metadata!r}"
|
||||
)
|
||||
|
||||
completed, markers = _await_completed_marker(client, key)
|
||||
_assert_aged_markers_terminal(markers)
|
||||
if completed is None:
|
||||
return
|
||||
|
||||
fetched = retrieve_batch(client, completed.id, key=key, provider=None)
|
||||
assert fetched.status == "completed", (
|
||||
f"listed-completed marker retrieved as {fetched.status!r}"
|
||||
)
|
||||
assert fetched.output_file_id, "completed batch has no output_file_id"
|
||||
|
||||
downloaded = client.proxy.transport.download(
|
||||
f"/v1/files/{fetched.output_file_id}/content",
|
||||
headers=client.proxy.transport.bearer(key),
|
||||
)
|
||||
assert downloaded.status_code == 200, (
|
||||
f"output content must be 200, got {downloaded.status_code}: {downloaded.body[:300]}"
|
||||
)
|
||||
first_line = BatchOutputLine.model_validate_json(downloaded.body.strip().splitlines()[0])
|
||||
assert first_line.response.status_code == 200, (
|
||||
f"batch output line reports failure: {downloaded.body[:400]}"
|
||||
)
|
||||
assert first_line.response.body is not None and first_line.response.body.choices, (
|
||||
"batch output line has no choices"
|
||||
)
|
||||
|
||||
rows = client.proxy.poll_logs_for_request_id(
|
||||
f"{fetched.id}_batch_cost",
|
||||
predicate=lambda found: any((row.spend or 0) > 0 for row in found),
|
||||
)
|
||||
priced = [row for row in rows if (row.spend or 0) > 0]
|
||||
assert priced, (
|
||||
f"completed batch {fetched.id} wrote no positive-cost spend row under "
|
||||
f"request_id {fetched.id}_batch_cost; cost write-back is broken (LIT-5730)"
|
||||
)
|
||||
cost_row = priced[0]
|
||||
assert cost_row.call_type == "aretrieve_batch", (
|
||||
f"batch cost row call_type={cost_row.call_type!r}"
|
||||
)
|
||||
assert (cost_row.total_tokens or 0) > 0, (
|
||||
f"batch cost row has no token usage: {cost_row.total_tokens!r}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
- {id: llm.responses.openai.basic.stream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Streaming via /v1/responses"}
|
||||
- {id: llm.responses.openai.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: nonstream, assertions: [works, cost_logged], source: "response_api_endpoints/endpoints.py:26", rationale: "Cost logged on responses"}
|
||||
- {id: llm.responses.openai.passthrough.stream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [cost_logged], source: "test_passthrough_e2e.py", rationale: "A streamed POST /openai_passthrough/v1/responses is costed and keyed by the provider response id; it used to log a zero-cost row under a random id (GitHub issue #36523)"}
|
||||
- {id: llm.responses.openai.passthrough_websocket.stream.works, module: llm, tier: P1, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [works], fail_before_fix: proven, source: "test_passthrough_e2e.py", rationale: "A websocket upgrade on /openai/v1/responses is accepted, so a responses.connect client reaches OpenAI through the same prefix its HTTP traffic uses; the prefix carried no websocket route and refused the upgrade with a 403 (GitHub issue #36088)"}
|
||||
- {id: llm.responses.openai.tool_use.nonstream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: tool_use, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Tool calls via Responses API"}
|
||||
- {id: llm.responses.openai.vision.nonstream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: vision, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Vision via Responses API"}
|
||||
- {id: llm.responses.anthropic.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: responses, route: anthropic, capability: basic, streaming: nonstream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Responses w/ Anthropic translation (smoke)"}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@
|
|||
- {id: llm.batches.hosted_vllm.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: hosted_vllm, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "hosted_vllm OpenAI-compatible batch create"}
|
||||
- {id: llm.batches.openai.key_model_access_denied.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Key model restriction 403 on upload/create"}
|
||||
- {id: llm.batches.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.18 / LIT-4778", rationale: "Missing input_file_id and invalid batch id rejected"}
|
||||
- {id: llm.batches.openai.terminal_state.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-5730", rationale: "A batch actually reaches completed and its output file downloads through GET /v1/files/{id}/content with per-line provider responses"}
|
||||
- {id: llm.batches.openai.terminal_state.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "test_batches_e2e.py / LIT-5730", fail_before_fix: proven, rationale: "Retrieving a completed model-encoded batch writes a positive spend row keyed {batch_id}_batch_cost (pins LIT-4852/LIT-5666; before the fix the logging worker 404d fetching the re-encoded output_file_id and the row was never written)"}
|
||||
- {id: llm.batches.openai.malformed_jsonl.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-5730", rationale: "Uploading a non-JSON batch file is rejected with a 400 naming the bad line"}
|
||||
- {id: llm.batches.openai.jsonl_endpoint_mismatch.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-5730", rationale: "JSONL line url that contradicts the batch endpoint drives the batch to failed with structured errors, retrieve stays clean, and the terminal retrieve books a zero-cost spend row (LIT-4852)"}
|
||||
- {id: llm.batches.openai.cancel_terminal.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-5730", rationale: "Cancelling an already-terminal batch returns a 409 conflict naming the terminal status"}
|
||||
- {id: llm.batches.openai.foreign_file_id.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-5730", rationale: "Create with one deployment's encoded file id and a conflicting model param routes by the file's embedded model; the returned batch id pins that precedence"}
|
||||
- {id: llm.batches.openai.second_hop.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-5347", rationale: "A litellm_proxy deployment chained to the gateway itself preserves target_model_names through nested unified ids; upload, create, and retrieve work over the two-hop chain (PR #36240)"}
|
||||
- {id: llm.files.openai.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "openai_files_endpoints/files_endpoints.py:46", rationale: "File upload returns OpenAIFileObject"}
|
||||
- {id: llm.files.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.16 / LIT-4778", rationale: "File upload without purpose rejected"}
|
||||
- {id: llm.files.openai.retrieve.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "files_endpoints.py", rationale: "File retrieve by id"}
|
||||
|
|
@ -40,10 +47,14 @@
|
|||
- {id: llm.files.hosted_vllm.upload.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: hosted_vllm, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "hosted_vllm OpenAI-compatible file upload"}
|
||||
- {id: llm.rerank.cohere.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: rerank, route: cohere, capability: basic, streaming: nonstream, assertions: [works], source: "test_rerank_e2e.py:29", rationale: "Cohere rerank, top_n + relevance_score"}
|
||||
- {id: llm.files.openai.content.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "GET /v1/files/{id}/content returns uploaded batch JSONL bytes"}
|
||||
- {id: llm.files.azure_openai.content.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: azure_openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-5730", rationale: "GET /v1/files/{id}/content on an Azure unified file returns the uploaded JSONL bytes verbatim"}
|
||||
- {id: llm.files.vertex.content.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-5730", rationale: "GET /v1/files/{id}/content on a Vertex unified file streams the GCS object back (provider-transformed JSONL, so asserts non-empty JSON lines rather than byte equality)"}
|
||||
- {id: llm.files.bedrock.content.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-5730", rationale: "GET /v1/files/{id}/content on a Bedrock unified file streams the S3 object back (provider-transformed JSONL, so asserts non-empty JSON lines rather than byte equality)"}
|
||||
- {id: llm.realtime.bedrock_converse.basic.stream.works, module: llm, tier: P0, subject_endpoint: realtime, route: bedrock_converse, capability: basic, streaming: stream, assertions: [works], source: "test_realtime_bedrock_e2e.py", rationale: "Nova Sonic realtime session emits response.done (LIT-2239)"}
|
||||
- {id: llm.google_native.gemini.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: google_native, route: gemini, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "LIT-4076 / proxy/google_endpoints/endpoints.py", fail_before_fix: proven, rationale: "google-native generateContent must stamp x-litellm-response-cost so SDK traffic reconciles against spend"}
|
||||
- {id: llm.google_native.gemini.basic.stream.works, module: llm, tier: P0, subject_endpoint: google_native, route: gemini, capability: basic, streaming: stream, assertions: [works], source: "PR #28213 / proxy/proxy_server.py async_data_generator", fail_before_fix: proven, rationale: "streamGenerateContent must relay single-prefixed SSE frames with no [DONE] sentinel; doubled data: prefixes and the OpenAI terminator both break the Vertex Java SDK"}
|
||||
- {id: llm.realtime.openai.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: realtime, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "vendor strategy §9.19 / LIT-4778", rationale: "HTTP /v1/realtime/client_secrets returns an ephemeral credential"}
|
||||
- {id: llm.realtime.openai.passthrough.stream.works, module: llm, tier: P0, subject_endpoint: realtime, route: openai, capability: basic, streaming: stream, assertions: [works], fail_before_fix: proven, source: "test_passthrough_e2e.py", rationale: "A websocket upgrade on /openai_passthrough/v1/realtime is accepted and relayed to OpenAI; only HTTP routes were registered under the prefix, so realtime clients were refused with a 403 before a socket existed (GitHub issue #36088)"}
|
||||
- {id: llm.vector_stores.openai.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: vector_stores, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "vendor strategy §9.17 / LIT-4778", rationale: "Vector store create/list/retrieve/delete lifecycle"}
|
||||
- {id: llm.vector_stores.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: vector_stores, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.17 / LIT-4778", rationale: "Vector store search and invalid id errors"}
|
||||
- {id: llm.bedrock_native.bedrock_converse.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: bedrock_native, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "vendor strategy §9.12 / LIT-4778", rationale: "Bedrock native converse happy path"}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,15 @@ ANOMALY_SPEND_SETTLE_SECONDS = float(
|
|||
)
|
||||
|
||||
|
||||
def ws_base_url() -> str:
|
||||
"""PROXY_BASE_URL with its scheme swapped for the websocket one, so a suite
|
||||
opening a socket points at the same proxy every HTTP suite uses."""
|
||||
for scheme, ws_scheme in (("https://", "wss://"), ("http://", "ws://")):
|
||||
if PROXY_BASE_URL.startswith(scheme):
|
||||
return ws_scheme + PROXY_BASE_URL[len(scheme) :]
|
||||
return PROXY_BASE_URL
|
||||
|
||||
|
||||
def datadog_mcp_url(*, toolsets: str = "core") -> str:
|
||||
"""Regional Datadog remote MCP endpoint for this process's DD_SITE.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ version + format version) plus one subdirectory per test, holding one JSON file
|
|||
per provider-bound interaction in call order. Bundles older than
|
||||
``MAX_BUNDLE_AGE`` hard-fail replay at collection time (see conftest), so a
|
||||
green replay run can never certify against fixtures that have drifted more than
|
||||
a week from the live providers.
|
||||
a week from the live providers. Bump ``BUNDLE_FORMAT_VERSION`` whenever a change
|
||||
moves recorded keys: a bundle recorded under the old rules then fails naming
|
||||
both versions instead of quietly missing on every call.
|
||||
|
||||
This module owns the format only. The provider-edge server that produces and
|
||||
consumes it lives in provider_edge.py (LIT-5745) and the canonical match keys
|
||||
|
|
@ -28,7 +30,7 @@ from typing import Final
|
|||
|
||||
from pydantic import BaseModel, JsonValue
|
||||
|
||||
BUNDLE_FORMAT_VERSION: Final = 2
|
||||
BUNDLE_FORMAT_VERSION: Final = 3
|
||||
MAX_BUNDLE_AGE: Final = timedelta(days=7)
|
||||
MANIFEST_FILENAME: Final = "manifest.json"
|
||||
|
||||
|
|
@ -47,7 +49,14 @@ class RecordedRequest(BaseModel):
|
|||
over ``method``, ``path`` (the edge path including the provider mount,
|
||||
query string excluded), and the canonicalized headers, params, body, form,
|
||||
and file identity. Non-JSON bodies store a canonicalized content digest
|
||||
instead of the bytes."""
|
||||
instead of the bytes.
|
||||
|
||||
``file_name`` is a JSON list of the uploaded parts' ``[field, filename,
|
||||
content-type]`` triples rather than a flat label, so a separator inside a
|
||||
filename cannot impersonate a field boundary. ``file_bytes`` is recorded for
|
||||
a reader's benefit and stays out of the key: the canonicalizer absorbs
|
||||
timestamp and id drift inside an uploaded file, and that drift moves the
|
||||
byte count."""
|
||||
|
||||
method: str
|
||||
path: str
|
||||
|
|
|
|||
|
|
@ -129,7 +129,6 @@ def canonicalize(request: RecordedRequest) -> CanonicalRequest:
|
|||
else {
|
||||
"name": None if request.file_name is None else canonical_string(request.file_name),
|
||||
"sha256": request.file_sha256,
|
||||
"bytes": request.file_bytes,
|
||||
}
|
||||
)
|
||||
content: Final[dict[str, JsonValue]] = {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ class RichMessagesRequest(BaseModel):
|
|||
max_tokens: int = 64
|
||||
system: list[TextBlock]
|
||||
messages: list[RichMessage]
|
||||
cache: dict[str, bool] = {"no-cache": True}
|
||||
|
||||
|
||||
class CompletionsRequest(BaseModel):
|
||||
|
|
|
|||
|
|
@ -11,9 +11,13 @@ native request models are co-located here because only this suite uses them.
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from websockets.exceptions import InvalidStatus
|
||||
from websockets.sync.client import connect
|
||||
|
||||
from e2e_config import ws_base_url
|
||||
from proxy_client import ProxyClient
|
||||
from e2e_http import FileUploadForm, Headers, NoBody, Result, StreamingResponse
|
||||
from models import ChatMessage
|
||||
|
|
@ -175,6 +179,26 @@ class OpenAIEmbeddingBody(BaseModel):
|
|||
input: str
|
||||
|
||||
|
||||
class WebsocketEnvelope(BaseModel):
|
||||
"""The one field every provider event carries, so the first frame off a
|
||||
passthrough socket identifies itself without the suite parsing raw dicts."""
|
||||
|
||||
type: str
|
||||
|
||||
|
||||
class WebsocketHandshake(BaseModel):
|
||||
"""What the proxy did with a websocket upgrade on a passthrough prefix.
|
||||
|
||||
`rejected_status` is the HTTP status of a refused upgrade: a prefix carrying no
|
||||
websocket route answers 403, before any socket exists. `first_event_type` is the
|
||||
type of the first frame an accepted socket delivered, which is None when the
|
||||
provider waits for the client to speak first.
|
||||
"""
|
||||
|
||||
rejected_status: int | None = None
|
||||
first_event_type: str | None = None
|
||||
|
||||
|
||||
class PassthroughBatchList(BaseModel):
|
||||
"""OpenAI's own batch page, relayed verbatim. `object` is required so a body
|
||||
that is not an OpenAI list fails validation instead of passing vacuously."""
|
||||
|
|
@ -339,5 +363,39 @@ class PassthroughClient:
|
|||
),
|
||||
)
|
||||
|
||||
# ---- OpenAI websocket passthrough ----------------------------------
|
||||
#
|
||||
# The same prefixes over an upgrade instead of a POST, for the provider APIs
|
||||
# that only speak websocket (realtime, responses.connect).
|
||||
|
||||
def openai_passthrough_websocket(
|
||||
self,
|
||||
key: str,
|
||||
path: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
open_timeout: float = 30.0,
|
||||
first_event_timeout: float = 30.0,
|
||||
) -> WebsocketHandshake:
|
||||
query = f"?{urlencode({'model': model})}" if model is not None else ""
|
||||
try:
|
||||
connection = connect(
|
||||
f"{ws_base_url()}{path}{query}",
|
||||
additional_headers={"Authorization": f"Bearer {key}"},
|
||||
open_timeout=open_timeout,
|
||||
)
|
||||
except InvalidStatus as rejected:
|
||||
return WebsocketHandshake(rejected_status=rejected.response.status_code)
|
||||
with connection:
|
||||
try:
|
||||
frame = connection.recv(timeout=first_event_timeout)
|
||||
except TimeoutError:
|
||||
return WebsocketHandshake()
|
||||
text = frame.decode("utf-8") if isinstance(frame, bytes) else frame
|
||||
return WebsocketHandshake(
|
||||
first_event_type=WebsocketEnvelope.model_validate_json(text).type
|
||||
)
|
||||
|
||||
|
||||
def build_client(proxy: ProxyClient) -> PassthroughClient:
|
||||
return PassthroughClient(proxy=proxy)
|
||||
|
|
|
|||
|
|
@ -21,20 +21,13 @@ from pydantic import BaseModel, ConfigDict
|
|||
from websockets.sync.client import connect
|
||||
from websockets.sync.connection import Connection
|
||||
|
||||
from e2e_config import PROXY_BASE_URL, unique_marker
|
||||
from e2e_config import unique_marker, ws_base_url
|
||||
from proxy_client import ProxyClient
|
||||
from models import LiteLLMParamsBody
|
||||
|
||||
_M = TypeVar("_M", bound=BaseModel)
|
||||
|
||||
|
||||
def ws_base_url() -> str:
|
||||
for scheme, ws_scheme in (("https://", "wss://"), ("http://", "ws://")):
|
||||
if PROXY_BASE_URL.startswith(scheme):
|
||||
return ws_scheme + PROXY_BASE_URL[len(scheme) :]
|
||||
return PROXY_BASE_URL
|
||||
|
||||
|
||||
def realtime_ws_url(model: str) -> str:
|
||||
return f"{ws_base_url()}/v1/realtime?{urlencode({'model': model})}"
|
||||
|
||||
|
|
|
|||
|
|
@ -27,10 +27,10 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
|
||||
from e2e_config import ws_base_url
|
||||
from realtime_client import (
|
||||
PROVIDERS,
|
||||
RealtimeProvider,
|
||||
ws_base_url,
|
||||
realtime_model,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ import asyncio
|
|||
|
||||
import pytest
|
||||
|
||||
from e2e_config import ws_base_url
|
||||
from realtime_client import (
|
||||
PROVIDERS,
|
||||
RealtimeProvider,
|
||||
ws_base_url,
|
||||
realtime_model,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Exercises the gateway against a live OpenAI deployment using customer request sh
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from e2e_config import unique_marker
|
||||
from e2e_config import provider_edge_base, unique_marker
|
||||
from e2e_http import StreamingResponse, assert_client_error, require_successful_call, unwrap
|
||||
from lifecycle import ResourceManager
|
||||
from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody
|
||||
|
|
@ -38,10 +38,15 @@ class ChatErrorEnvelope(BaseModel):
|
|||
|
||||
|
||||
def _register_chat_model(proxy: ProxyClient, resources: ResourceManager) -> tuple[str, str]:
|
||||
base = provider_edge_base("openai")
|
||||
model = f"e2e-chat-sec-{unique_marker()}"
|
||||
model_id = proxy.create_model(
|
||||
model,
|
||||
LiteLLMParamsBody(model=OPENAI_BACKEND, api_key="os.environ/OPENAI_API_KEY"),
|
||||
LiteLLMParamsBody(
|
||||
model=OPENAI_BACKEND,
|
||||
api_key="os.environ/OPENAI_API_KEY",
|
||||
api_base=None if base is None else f"{base}/v1",
|
||||
),
|
||||
)
|
||||
resources.defer(lambda: proxy.delete_model(model_id))
|
||||
return model, resources.key()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ covered by tests/e2e/quota_management/spend_tracking/.
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from e2e_config import unique_marker
|
||||
from e2e_config import provider_edge_base, unique_marker
|
||||
from e2e_http import (
|
||||
assert_client_error,
|
||||
require_successful_call,
|
||||
|
|
@ -27,6 +27,18 @@ class _OptionalEmbeddingsBody(BaseModel):
|
|||
input: str | list[str] | None = None
|
||||
|
||||
|
||||
def _openai_embeddings_params() -> LiteLLMParamsBody:
|
||||
"""The OpenAI embeddings deployment, wired through the record/replay edge when a
|
||||
fixture mode is active and straight at OpenAI otherwise (LIT-5974). Bedrock and
|
||||
Vertex stay live: SigV4 signs the Host header, and neither has an edge mount."""
|
||||
base = provider_edge_base("openai")
|
||||
return LiteLLMParamsBody(
|
||||
model="openai/text-embedding-3-small",
|
||||
api_key="os.environ/OPENAI_API_KEY",
|
||||
api_base=None if base is None else f"{base}/v1",
|
||||
)
|
||||
|
||||
|
||||
class TestEmbeddingsEndpoint:
|
||||
@pytest.mark.covers("llm.embeddings.openai.basic.nonstream.works")
|
||||
def test_embeddings_returns_vector(
|
||||
|
|
@ -35,9 +47,7 @@ class TestEmbeddingsEndpoint:
|
|||
model = f"e2e-embeddings-{unique_marker()}"
|
||||
model_id = endpoints_client.create_model(
|
||||
model,
|
||||
LiteLLMParamsBody(
|
||||
model="openai/text-embedding-3-small", api_key="os.environ/OPENAI_API_KEY"
|
||||
),
|
||||
_openai_embeddings_params(),
|
||||
)
|
||||
resources.defer(lambda: endpoints_client.delete_model(model_id))
|
||||
key = resources.key()
|
||||
|
|
@ -106,9 +116,7 @@ class TestEmbeddingsEndpoint:
|
|||
model = f"e2e-embeddings-array-{unique_marker()}"
|
||||
model_id = endpoints_client.create_model(
|
||||
model,
|
||||
LiteLLMParamsBody(
|
||||
model="openai/text-embedding-3-small", api_key="os.environ/OPENAI_API_KEY"
|
||||
),
|
||||
_openai_embeddings_params(),
|
||||
)
|
||||
resources.defer(lambda: endpoints_client.delete_model(model_id))
|
||||
key = resources.key()
|
||||
|
|
@ -140,9 +148,7 @@ class TestEmbeddingsEndpoint:
|
|||
model = f"e2e-embeddings-missin-{unique_marker()}"
|
||||
model_id = endpoints_client.create_model(
|
||||
model,
|
||||
LiteLLMParamsBody(
|
||||
model="openai/text-embedding-3-small", api_key="os.environ/OPENAI_API_KEY"
|
||||
),
|
||||
_openai_embeddings_params(),
|
||||
)
|
||||
resources.defer(lambda: endpoints_client.delete_model(model_id))
|
||||
key = resources.key()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ litellm-regression-tests/tests/test_inference_endpoints.py.
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from e2e_config import unique_marker
|
||||
from e2e_config import provider_edge_base, unique_marker
|
||||
from e2e_http import assert_client_error, require_successful_call, unwrap
|
||||
from endpoints_client import EndpointsClient, MessagesResult
|
||||
from lifecycle import ResourceManager
|
||||
|
|
@ -50,16 +50,27 @@ def _approx_equal(actual: float, expected: float) -> bool:
|
|||
return abs(actual - expected) <= max(1e-9, abs(expected) * 1e-2)
|
||||
|
||||
|
||||
def _anthropic_params() -> LiteLLMParamsBody:
|
||||
"""The Anthropic deployment, wired through the record/replay edge when a fixture
|
||||
mode is active (LIT-5974). The mount base carries no ``/v1``: litellm's Anthropic
|
||||
handler appends ``/v1/messages`` to ``api_base`` itself, where the OpenAI handler
|
||||
appends only ``/chat/completions``."""
|
||||
base = provider_edge_base("anthropic")
|
||||
return LiteLLMParamsBody(
|
||||
model=ANTHROPIC_BACKEND, api_key="os.environ/ANTHROPIC_API_KEY", api_base=base
|
||||
)
|
||||
|
||||
|
||||
class TestAnthropicMessages:
|
||||
def _register(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
self,
|
||||
endpoints_client: EndpointsClient,
|
||||
resources: ResourceManager,
|
||||
params: LiteLLMParamsBody | None = None,
|
||||
) -> tuple[str, str]:
|
||||
model = f"e2e-messages-{unique_marker()}"
|
||||
model_id = endpoints_client.create_model(
|
||||
model,
|
||||
LiteLLMParamsBody(
|
||||
model=ANTHROPIC_BACKEND, api_key="os.environ/ANTHROPIC_API_KEY"
|
||||
),
|
||||
model, _anthropic_params() if params is None else params
|
||||
)
|
||||
resources.defer(lambda: endpoints_client.delete_model(model_id))
|
||||
return model, resources.key()
|
||||
|
|
@ -81,12 +92,7 @@ class TestAnthropicMessages:
|
|||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model = f"e2e-messages-cost-{unique_marker()}"
|
||||
model_id = endpoints_client.create_model(
|
||||
model,
|
||||
LiteLLMParamsBody(
|
||||
model=ANTHROPIC_BACKEND, api_key="os.environ/ANTHROPIC_API_KEY"
|
||||
),
|
||||
)
|
||||
model_id = endpoints_client.create_model(model, _anthropic_params())
|
||||
resources.defer(lambda: endpoints_client.delete_model(model_id))
|
||||
key = resources.key()
|
||||
|
||||
|
|
@ -131,7 +137,13 @@ class TestAnthropicMessages:
|
|||
def test_messages_streams_completion(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
"""Stays on a live Anthropic deployment in every mode: the edge buffers a
|
||||
streamed response into one body, so chunk fidelity waits on LIT-5742."""
|
||||
model, key = self._register(
|
||||
endpoints_client,
|
||||
resources,
|
||||
LiteLLMParamsBody(model=ANTHROPIC_BACKEND, api_key="os.environ/ANTHROPIC_API_KEY"),
|
||||
)
|
||||
|
||||
result = endpoints_client.proxy.messages_stream(
|
||||
key,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from passthrough_client import (
|
|||
)
|
||||
|
||||
EMBEDDING_MODEL = "text-embedding-3-small"
|
||||
REALTIME_MODEL = "gpt-realtime-2"
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
|
@ -339,3 +340,52 @@ class TestOpenAIPassthroughSpend:
|
|||
f"the embeddings row logged no prompt tokens, so whatever cost it carries "
|
||||
f"was not computed from the real usage: {row}"
|
||||
)
|
||||
|
||||
|
||||
class TestOpenAIPassthroughWebsocket:
|
||||
"""The OpenAI passthrough prefixes must answer a websocket upgrade, not only a POST.
|
||||
|
||||
The customer points realtime and responses.connect clients at the same prefixes
|
||||
their HTTP traffic already uses. Only HTTP routes were registered under those
|
||||
prefixes, so every upgrade was refused before a socket existed and those clients
|
||||
could not reach the gateway at all. A refused upgrade is an HTTP response, not a
|
||||
close frame, which is why these assert on the handshake rather than a close code.
|
||||
"""
|
||||
|
||||
@pytest.mark.covers("llm.realtime.openai.passthrough.stream.works")
|
||||
def test_realtime_upgrade_reaches_openai_through_the_passthrough_prefix(
|
||||
self, client: PassthroughClient, scoped_key: str
|
||||
) -> None:
|
||||
"""Pins GitHub issue #36088: /openai_passthrough/v1/realtime accepts the
|
||||
upgrade and relays OpenAI's own session, instead of rejecting it with a 403."""
|
||||
handshake = client.openai_passthrough_websocket(
|
||||
scoped_key, "/openai_passthrough/v1/realtime", model=REALTIME_MODEL
|
||||
)
|
||||
|
||||
assert handshake.rejected_status is None, (
|
||||
f"/openai_passthrough/v1/realtime refused the websocket upgrade with HTTP "
|
||||
f"{handshake.rejected_status}, so a realtime client cannot connect through "
|
||||
"the gateway at all"
|
||||
)
|
||||
assert handshake.first_event_type == "session.created", (
|
||||
"the accepted socket never carried OpenAI's opening session event, so the "
|
||||
f"upgrade was not relayed upstream; the first frame was "
|
||||
f"{handshake.first_event_type}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("llm.responses.openai.passthrough_websocket.stream.works")
|
||||
def test_responses_upgrade_is_accepted_on_the_openai_prefix(
|
||||
self, client: PassthroughClient, scoped_key: str
|
||||
) -> None:
|
||||
"""Pins GitHub issue #36088 on the second prefix: /openai/v1/responses upgrades
|
||||
as well. A responses.connect socket waits for the client to speak first, so the
|
||||
accepted handshake is the whole signal here."""
|
||||
handshake = client.openai_passthrough_websocket(
|
||||
scoped_key, "/openai/v1/responses", first_event_timeout=2.0
|
||||
)
|
||||
|
||||
assert handshake.rejected_status is None, (
|
||||
f"/openai/v1/responses refused the websocket upgrade with HTTP "
|
||||
f"{handshake.rejected_status}; the prefix relays this route over HTTP but "
|
||||
"drops a responses.connect client before the socket opens"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,10 +20,9 @@ headers must never touch disk. An unmatched replay call returns HTTP
|
|||
proxy relays as a provider error the failing test surfaces.
|
||||
|
||||
v1 limits: only the mounts in ``EDGE_MOUNTS`` (SigV4 providers like Bedrock
|
||||
sign the Host header, so a forwarding edge breaks their signatures), JSON and
|
||||
opaque single-part bodies (multipart boundaries are random per request),
|
||||
streaming fidelity is LIT-5742, and CI wiring is LIT-5748. Suites that do not
|
||||
wire the edge keep hitting providers live in every mode.
|
||||
sign the Host header, so a forwarding edge breaks their signatures), streaming
|
||||
fidelity is LIT-5742, and CI wiring is LIT-5748. Suites that do not wire the
|
||||
edge keep hitting providers live in every mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -32,6 +31,7 @@ import base64
|
|||
import difflib
|
||||
import functools
|
||||
import hashlib
|
||||
import re
|
||||
import threading
|
||||
from collections import deque
|
||||
from collections.abc import Mapping
|
||||
|
|
@ -59,7 +59,13 @@ from fixture_bundle import (
|
|||
prepare_bundle,
|
||||
slug_for_test,
|
||||
)
|
||||
from fixture_canonical import CanonicalRequest, canonical_string, canonicalize
|
||||
from fixture_canonical import (
|
||||
SECRET_PLACEHOLDER,
|
||||
CanonicalRequest,
|
||||
canonical_string,
|
||||
canonicalize,
|
||||
is_secret_field,
|
||||
)
|
||||
from fixture_mode import (
|
||||
FIXTURE_MODES,
|
||||
InvalidFixtureMode,
|
||||
|
|
@ -103,26 +109,244 @@ _RESPONSE_DROPPED_HEADERS: Final[frozenset[str]] = _HOP_BY_HOP_HEADERS | {
|
|||
_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
||||
|
||||
|
||||
def _edge_request(method: str, path: str, query: str, body: bytes | None) -> RecordedRequest:
|
||||
"""The identity replay matches on: the edge path (mount included), the query
|
||||
as params, and the body as parsed JSON, or as a canonicalized content digest
|
||||
when it is not JSON so opaque uploads still match across runs."""
|
||||
params: Final = dict(parse_qsl(query, keep_blank_values=True))
|
||||
if not body:
|
||||
return RecordedRequest(method=method.lower(), path=path, headers={}, params=params)
|
||||
decoded: Final = body.decode("utf-8", errors="replace")
|
||||
_BOUNDARY_PATTERN: Final = re.compile(
|
||||
r'(?:^|;)\s*boundary\s*=\s*(?:"([^"]*)"|([^;,\s]+))', re.IGNORECASE
|
||||
)
|
||||
_DISPOSITION_NAME_PATTERN: Final = re.compile(r'(?:^|;)\s*name="([^"]*)"', re.IGNORECASE)
|
||||
_DISPOSITION_FILENAME_PATTERN: Final = re.compile(
|
||||
r'(?:^|;)\s*filename="([^"]*)"', re.IGNORECASE
|
||||
)
|
||||
_UNPARSED_MULTIPART: Final = "<unparsed-multipart>"
|
||||
_BOUNDARY_PLACEHOLDER: Final = b"--<boundary>"
|
||||
_BINARY_FIELD_PREFIX: Final = "<binary:sha256:"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _MultipartPart:
|
||||
field_name: str
|
||||
filename: str | None
|
||||
content: bytes
|
||||
content_type: str = ""
|
||||
|
||||
|
||||
def _header_value(headers: Mapping[str, str], name: str) -> str:
|
||||
wanted: Final = name.lower()
|
||||
return next((value for key, value in headers.items() if key.lower() == wanted), "")
|
||||
|
||||
|
||||
def _multipart_boundary(content_type: str) -> str | None:
|
||||
"""The declared boundary, or None when the envelope is not multipart or names no
|
||||
usable boundary. ``boundary`` is matched only as a parameter in its own right, so a
|
||||
longer name ending in it (``myboundary=``) is not mistaken for one, and an empty
|
||||
boundary is refused rather than splitting the body on a bare ``--``."""
|
||||
if "multipart/form-data" not in content_type.lower():
|
||||
return None
|
||||
match: Final = _BOUNDARY_PATTERN.search(content_type)
|
||||
if match is None:
|
||||
return None
|
||||
quoted, bare = match.group(1), match.group(2)
|
||||
return (quoted if quoted is not None else bare) or None
|
||||
|
||||
|
||||
def _part_headers(head: bytes) -> dict[str, str]:
|
||||
return {
|
||||
name.strip().lower(): value.strip()
|
||||
for line in head.decode("utf-8", errors="replace").split("\r\n")
|
||||
for name, separator, value in [line.partition(":")]
|
||||
if separator
|
||||
}
|
||||
|
||||
|
||||
def _parse_multipart_part(segment: bytes) -> _MultipartPart | None:
|
||||
head, separator, content = segment.partition(b"\r\n\r\n")
|
||||
if not separator:
|
||||
return None
|
||||
headers: Final = _part_headers(head)
|
||||
disposition: Final = headers.get("content-disposition", "")
|
||||
name_match: Final = _DISPOSITION_NAME_PATTERN.search(disposition)
|
||||
if name_match is None:
|
||||
return None
|
||||
filename_match: Final = _DISPOSITION_FILENAME_PATTERN.search(disposition)
|
||||
return _MultipartPart(
|
||||
field_name=name_match.group(1),
|
||||
filename=None if filename_match is None else filename_match.group(1),
|
||||
content=content,
|
||||
content_type=headers.get("content-type", ""),
|
||||
)
|
||||
|
||||
|
||||
def _multipart_parts(body: bytes, boundary: str) -> tuple[_MultipartPart, ...] | None:
|
||||
"""The wire body split back into its parts, or None when it does not parse as the
|
||||
declared envelope so the caller can fall back to the opaque content digest."""
|
||||
segments: Final = body.split(b"--" + boundary.encode())
|
||||
if len(segments) < 3 or not segments[-1].startswith(b"--"):
|
||||
return None
|
||||
parsed: Final = tuple(
|
||||
_parse_multipart_part(segment.removeprefix(b"\r\n").removesuffix(b"\r\n"))
|
||||
for segment in segments[1:-1]
|
||||
)
|
||||
if any(part is None for part in parsed):
|
||||
return None
|
||||
return tuple(part for part in parsed if part is not None)
|
||||
|
||||
|
||||
def _content_digest(content: bytes) -> str:
|
||||
"""Text is canonicalized before hashing so a per-run marker inside an uploaded JSONL
|
||||
does not move the key; anything that is not UTF-8 is hashed byte for byte, since a
|
||||
lossy decode collapses every binary payload of one length onto one digest."""
|
||||
try:
|
||||
parsed: Final[JsonValue] = _JSON.validate_json(decoded)
|
||||
except ValueError:
|
||||
return RecordedRequest(
|
||||
method=method.lower(),
|
||||
path=path,
|
||||
headers={},
|
||||
params=params,
|
||||
file_sha256=hashlib.sha256(canonical_string(decoded).encode()).hexdigest(),
|
||||
file_bytes=len(body),
|
||||
text: Final = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
return hashlib.sha256(canonical_string(text).encode()).hexdigest()
|
||||
|
||||
|
||||
def _is_file_part(part: _MultipartPart) -> bool:
|
||||
"""Whether a part is an upload rather than an ordinary field. A filename says so
|
||||
outright, and so does a declared content type: clients attach one per part only for
|
||||
a file, and a client that omits the filename (httpx drops the parameter when it is
|
||||
empty) would otherwise have the file's bytes stored inline as a field value and key
|
||||
identically to a plain field of the same name."""
|
||||
return part.filename is not None or bool(part.content_type)
|
||||
|
||||
|
||||
def _field_value(part: _MultipartPart) -> str:
|
||||
"""What a field part contributes to the stored form. A secret-named field never has
|
||||
its value written out, since the bundle is a file on disk and the key redacts that
|
||||
field to the same placeholder either way, so replay still matches. A value that is
|
||||
not UTF-8 is carried as a digest rather than decoded lossily, because a replacing
|
||||
decode collapses every binary value of one length onto one string. That digest is
|
||||
base64 rather than hex, since the canonicalizer rewrites any long hex run to a
|
||||
``<sha256>`` placeholder and would collapse the values right back together."""
|
||||
if is_secret_field(part.field_name):
|
||||
return SECRET_PLACEHOLDER
|
||||
try:
|
||||
return part.content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
digest: Final = base64.b64encode(hashlib.sha256(part.content).digest()).decode()
|
||||
return f"{_BINARY_FIELD_PREFIX}{digest}>"
|
||||
|
||||
|
||||
def _form_fields(fields: tuple[_MultipartPart, ...]) -> dict[str, str]:
|
||||
"""The ordinary field parts, flattened into the mapping the bundle format stores. A
|
||||
name sent more than once takes an occurrence suffix instead of overwriting the
|
||||
earlier value, so nothing an upload said is dropped from its key. The suffix is
|
||||
escaped so a field literally named ``x[1]`` cannot collide with a second ``x``."""
|
||||
form: dict[str, str] = {}
|
||||
for part in fields:
|
||||
name = part.field_name.replace("[", "[[")
|
||||
occurrence = 1
|
||||
while name in form:
|
||||
name = f"{part.field_name.replace('[', '[[')}[{occurrence}]"
|
||||
occurrence += 1
|
||||
form[name] = _field_value(part)
|
||||
return form
|
||||
|
||||
|
||||
def _file_identity(files: tuple[_MultipartPart, ...]) -> tuple[str | None, str | None, int | None]:
|
||||
"""Name, content digest, and total length for the uploaded file parts.
|
||||
|
||||
The name is a structured list of every part's field name, filename, and declared
|
||||
content type rather than a joined string, so a filename containing the separator
|
||||
cannot be confused for a different split, and two parts that differ only in the type
|
||||
they declare stay apart. It goes through the canonicalizer as one string, which is
|
||||
why per-run markers inside a filename do not move the key in the multi-file case any
|
||||
more than they do in the single-file one.
|
||||
|
||||
The digest covers content only. A lone file keeps its own canonicalized digest;
|
||||
several fold into one ordered digest, so parts arriving in a different order key
|
||||
differently. Total length is recorded for a reader but deliberately kept out of the
|
||||
key: it is the raw byte count, and keying on it would undo exactly the drift the
|
||||
canonicalized digest exists to absorb."""
|
||||
if not files:
|
||||
return None, None, None
|
||||
names: Final = _JSON.dump_json(
|
||||
[[part.field_name, part.filename, part.content_type] for part in files]
|
||||
).decode()
|
||||
total: Final = sum(len(part.content) for part in files)
|
||||
if len(files) == 1:
|
||||
return names, _content_digest(files[0].content), total
|
||||
folded: Final = _JSON.dump_json([_content_digest(part.content) for part in files])
|
||||
return names, hashlib.sha256(folded).hexdigest(), total
|
||||
|
||||
|
||||
def _multipart_request(
|
||||
method: str, path: str, params: dict[str, str], parts: tuple[_MultipartPart, ...]
|
||||
) -> RecordedRequest:
|
||||
"""A multipart upload keyed by what it says rather than by its wire bytes: every
|
||||
ordinary field, plus the identity of the uploaded file. The random per-request
|
||||
boundary is envelope, never content, so it never reaches the digest."""
|
||||
form: Final = _form_fields(tuple(part for part in parts if not _is_file_part(part)))
|
||||
file_name, file_sha256, file_bytes = _file_identity(
|
||||
tuple(part for part in parts if _is_file_part(part))
|
||||
)
|
||||
return RecordedRequest(
|
||||
method=method,
|
||||
path=path,
|
||||
headers={},
|
||||
params=params,
|
||||
form=form,
|
||||
file_name=file_name,
|
||||
file_sha256=file_sha256,
|
||||
file_bytes=file_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _opaque_request(
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, str],
|
||||
body: bytes,
|
||||
digested: bytes,
|
||||
file_name: str | None = None,
|
||||
) -> RecordedRequest:
|
||||
"""A body kept out of the bundle and matched on its digest alone. ``digested`` is
|
||||
what the digest runs over, which is the body itself unless something in it has to be
|
||||
normalized away first."""
|
||||
return RecordedRequest(
|
||||
method=method,
|
||||
path=path,
|
||||
headers={},
|
||||
params=params,
|
||||
file_name=file_name,
|
||||
file_sha256=_content_digest(digested),
|
||||
file_bytes=len(body),
|
||||
)
|
||||
|
||||
|
||||
def edge_request(
|
||||
method: str, path: str, query: str, body: bytes | None, content_type: str = ""
|
||||
) -> RecordedRequest:
|
||||
"""The identity replay matches on: the edge path (mount included), the query as
|
||||
params, and the body as parsed JSON, as parsed multipart fields and file identity
|
||||
when the content type declares an envelope, or as a content digest otherwise so
|
||||
opaque uploads still match across runs. A multipart body that does not parse still
|
||||
has its boundary normalized away, because that boundary is fresh every request and
|
||||
would otherwise guarantee a miss."""
|
||||
params: Final = dict(parse_qsl(query, keep_blank_values=True))
|
||||
lowered_method: Final = method.lower()
|
||||
if not body:
|
||||
return RecordedRequest(method=lowered_method, path=path, headers={}, params=params)
|
||||
boundary: Final = _multipart_boundary(content_type)
|
||||
if boundary is not None:
|
||||
parts = _multipart_parts(body, boundary)
|
||||
if parts is not None:
|
||||
return _multipart_request(lowered_method, path, params, parts)
|
||||
return _opaque_request(
|
||||
lowered_method,
|
||||
path,
|
||||
params,
|
||||
body,
|
||||
body.replace(b"--" + boundary.encode(), _BOUNDARY_PLACEHOLDER),
|
||||
_UNPARSED_MULTIPART,
|
||||
)
|
||||
return RecordedRequest(method=method.lower(), path=path, headers={}, params=params, body=parsed)
|
||||
try:
|
||||
parsed: Final[JsonValue] = _JSON.validate_json(body)
|
||||
except ValueError:
|
||||
return _opaque_request(lowered_method, path, params, body, body)
|
||||
return RecordedRequest(
|
||||
method=lowered_method, path=path, headers={}, params=params, body=parsed
|
||||
)
|
||||
|
||||
|
||||
def _build_pool(recorded: tuple[Interaction, ...]) -> dict[str, deque[Interaction]]:
|
||||
|
|
@ -351,7 +575,9 @@ def handle_edge_request(
|
|||
return _text_reply(
|
||||
404, f"unknown provider mount {mount!r}; known mounts: {', '.join(sorted(mounts))}"
|
||||
)
|
||||
request: Final = _edge_request(method, split.path, split.query, body)
|
||||
request: Final = edge_request(
|
||||
method, split.path, split.query, body, _header_value(headers, "content-type")
|
||||
)
|
||||
match backend:
|
||||
case RecordEdge():
|
||||
return _handle_record(
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def chat_override(
|
|||
json=ReliabilityChatBody(
|
||||
model=model,
|
||||
messages=[ChatMessage(role="user", content=content)],
|
||||
max_tokens=16,
|
||||
max_tokens=64,
|
||||
stream=stream,
|
||||
router_settings_override=override,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class TestReliabilityFallbacks:
|
|||
resources.defer(lambda: client.proxy.delete_model(model_id))
|
||||
|
||||
resp = chat_override(
|
||||
client.proxy, scoped_key, primary, "say hi",
|
||||
client.proxy, scoped_key, primary, f"say hi {unique_marker()}",
|
||||
override=RouterSettingsOverride(fallbacks=[{primary: ["gpt-5.5"]}]),
|
||||
)
|
||||
_assert_served_by_fallback(resp)
|
||||
|
|
@ -63,7 +63,7 @@ class TestReliabilityFallbacks:
|
|||
resources.defer(lambda: client.proxy.delete_model(model_id))
|
||||
|
||||
resp = chat_override(
|
||||
client.proxy, scoped_key, primary, "say hi",
|
||||
client.proxy, scoped_key, primary, f"say hi {unique_marker()}",
|
||||
override=RouterSettingsOverride(fallbacks=[{primary: ["gpt-5.5"]}]),
|
||||
)
|
||||
_assert_served_by_fallback(resp)
|
||||
|
|
|
|||
|
|
@ -23,11 +23,13 @@ from concurrent.futures import ThreadPoolExecutor
|
|||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from e2e_http import RawResponse, forward
|
||||
from fixture_canonical import canonicalize
|
||||
from fixture_bundle import (
|
||||
BundleRecorder,
|
||||
Interaction,
|
||||
|
|
@ -46,6 +48,7 @@ from provider_edge import (
|
|||
RecordEdge,
|
||||
ReplayEdge,
|
||||
ReplaySource,
|
||||
edge_request,
|
||||
handle_edge_request,
|
||||
provider_edge_api_base,
|
||||
replay_leftover_error,
|
||||
|
|
@ -53,8 +56,10 @@ from provider_edge import (
|
|||
)
|
||||
|
||||
CHAT_PATH = "/openai/v1/chat/completions"
|
||||
UPLOAD_PATH = "/openai/v1/files"
|
||||
REPLAY_MOUNTS = {"openai": "https://replay.invalid"}
|
||||
JSON_OBJECT = TypeAdapter(dict[str, object])
|
||||
BATCH_JSONL = b'{"custom_id":"one"}\n{"custom_id":"two"}\n'
|
||||
|
||||
|
||||
def json_object(body: bytes) -> dict[str, object]:
|
||||
|
|
@ -164,6 +169,46 @@ def chat_body(prompt: str) -> bytes:
|
|||
return json.dumps({"model": "gpt", "messages": [{"role": "user", "content": prompt}]}).encode()
|
||||
|
||||
|
||||
def multipart_body(
|
||||
boundary: str,
|
||||
fields: tuple[tuple[str, str], ...] = (),
|
||||
files: tuple[tuple[str, str, bytes], ...] = (),
|
||||
) -> bytes:
|
||||
"""One multipart/form-data body on the wire, exactly as ``requests`` writes it, with
|
||||
the boundary under the caller's control instead of randomly generated."""
|
||||
parts = [
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
|
||||
+ value.encode()
|
||||
for name, value in fields
|
||||
] + [
|
||||
(
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"; '
|
||||
f'filename="{filename}"\r\nContent-Type: application/octet-stream\r\n\r\n'
|
||||
).encode()
|
||||
+ content
|
||||
for name, filename, content in files
|
||||
]
|
||||
return b"\r\n".join(parts) + f"\r\n--{boundary}--\r\n".encode()
|
||||
|
||||
|
||||
def upload_headers(boundary: str) -> dict[str, str]:
|
||||
return {
|
||||
"content-type": f"multipart/form-data; boundary={boundary}",
|
||||
"authorization": "Bearer sk-upload-secret",
|
||||
}
|
||||
|
||||
|
||||
def record_upload(root: Path, body: bytes, boundary: str) -> None:
|
||||
with fake_provider() as provider:
|
||||
with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge:
|
||||
call_edge(edge, "POST", UPLOAD_PATH, body=body, headers=upload_headers(boundary))
|
||||
|
||||
|
||||
def replay_upload(root: Path, body: bytes, boundary: str) -> RawResponse:
|
||||
with running_edge(ReplayEdge(source=replay_source(root)), REPLAY_MOUNTS) as edge:
|
||||
return call_edge(edge, "POST", UPLOAD_PATH, body=body, headers=upload_headers(boundary))
|
||||
|
||||
|
||||
class TestRecordMode:
|
||||
def test_forwards_to_the_provider_and_writes_one_interaction_file(self, tmp_path: Path) -> None:
|
||||
root = tmp_path / "bundle"
|
||||
|
|
@ -328,6 +373,347 @@ class TestReplayMode:
|
|||
assert replayed.status_code == 200
|
||||
|
||||
|
||||
class TestMultipartIdentity:
|
||||
"""LIT-5974: a multipart upload is keyed by its parsed fields and file identity.
|
||||
``requests`` picks a fresh random boundary per request, so hashing the wire body
|
||||
made every upload miss on replay; parsing the envelope keys the upload on what it
|
||||
actually says, which is stable across runs and still separates real drift."""
|
||||
|
||||
def test_a_fresh_boundary_replays_the_same_upload(self, tmp_path: Path) -> None:
|
||||
root = tmp_path / "bundle"
|
||||
recorded = multipart_body(
|
||||
"d0a1b2c3d4e5f60718293a4b5c6d7e8f",
|
||||
fields=(("purpose", "batch"),),
|
||||
files=(("file", "batch.jsonl", BATCH_JSONL),),
|
||||
)
|
||||
record_upload(root, recorded, "d0a1b2c3d4e5f60718293a4b5c6d7e8f")
|
||||
|
||||
rerun = multipart_body(
|
||||
"ffffeeeeddddccccbbbbaaaa99998888",
|
||||
fields=(("purpose", "batch"),),
|
||||
files=(("file", "batch.jsonl", BATCH_JSONL),),
|
||||
)
|
||||
assert rerun != recorded
|
||||
replayed = replay_upload(root, rerun, "ffffeeeeddddccccbbbbaaaa99998888")
|
||||
assert replayed.status_code == 200, replayed.body[:400]
|
||||
|
||||
def test_the_stored_request_carries_fields_and_file_identity_but_no_secrets(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
root = tmp_path / "bundle"
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
record_upload(
|
||||
root,
|
||||
multipart_body(
|
||||
boundary,
|
||||
fields=(("purpose", "batch"),),
|
||||
files=(("file", "batch.jsonl", BATCH_JSONL),),
|
||||
),
|
||||
boundary,
|
||||
)
|
||||
|
||||
raw = this_tests_files(root)[0].read_text(encoding="utf-8")
|
||||
interaction = Interaction.model_validate_json(raw)
|
||||
assert interaction.request.form == {"purpose": "batch"}
|
||||
assert interaction.request.file_name == json.dumps(
|
||||
[["file", "batch.jsonl", "application/octet-stream"]], separators=(",", ":")
|
||||
)
|
||||
assert interaction.request.file_bytes == len(BATCH_JSONL)
|
||||
stored = interaction.request.model_dump_json()
|
||||
assert boundary not in stored
|
||||
assert "sk-upload-secret" not in stored
|
||||
assert "custom_id" not in stored
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fields", "files"),
|
||||
[
|
||||
pytest.param(
|
||||
(("purpose", "batch"),),
|
||||
(("file", "batch.jsonl", b'{"custom_id":"three"}\n'),),
|
||||
id="file-content",
|
||||
),
|
||||
pytest.param(
|
||||
(("purpose", "batch"),),
|
||||
(("file", "other.jsonl", BATCH_JSONL),),
|
||||
id="file-name",
|
||||
),
|
||||
pytest.param(
|
||||
(("purpose", "fine-tune"),),
|
||||
(("file", "batch.jsonl", BATCH_JSONL),),
|
||||
id="form-field",
|
||||
),
|
||||
pytest.param(
|
||||
(("purpose", "batch"), ("purpose", "batch")),
|
||||
(("file", "batch.jsonl", BATCH_JSONL),),
|
||||
id="repeated-form-field",
|
||||
),
|
||||
pytest.param(
|
||||
(("purpose", "batch"),),
|
||||
(
|
||||
("file", "batch.jsonl", BATCH_JSONL),
|
||||
("mask", "mask.jsonl", BATCH_JSONL),
|
||||
),
|
||||
id="extra-file-part",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_a_structurally_different_upload_misses(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
fields: tuple[tuple[str, str], ...],
|
||||
files: tuple[tuple[str, str, bytes], ...],
|
||||
) -> None:
|
||||
root = tmp_path / "bundle"
|
||||
record_upload(
|
||||
root,
|
||||
multipart_body(
|
||||
"aaaaaaaabbbbbbbbccccccccdddddddd",
|
||||
fields=(("purpose", "batch"),),
|
||||
files=(("file", "batch.jsonl", BATCH_JSONL),),
|
||||
),
|
||||
"aaaaaaaabbbbbbbbccccccccdddddddd",
|
||||
)
|
||||
|
||||
drifted = replay_upload(
|
||||
root,
|
||||
multipart_body("11112222333344445555666677778888", fields=fields, files=files),
|
||||
"11112222333344445555666677778888",
|
||||
)
|
||||
assert drifted.status_code == REPLAY_MISS_STATUS
|
||||
|
||||
def test_several_file_parts_separate_when_their_contents_swap(self, tmp_path: Path) -> None:
|
||||
root = tmp_path / "bundle"
|
||||
image, mask = b"image-bytes", b"mask-bytes"
|
||||
record_upload(
|
||||
root,
|
||||
multipart_body(
|
||||
"1a1a1a1a2b2b2b2b3c3c3c3c4d4d4d4d",
|
||||
fields=(("prompt", "a cat"),),
|
||||
files=(("image", "a.png", image), ("mask", "b.png", mask)),
|
||||
),
|
||||
"1a1a1a1a2b2b2b2b3c3c3c3c4d4d4d4d",
|
||||
)
|
||||
|
||||
swapped = replay_upload(
|
||||
root,
|
||||
multipart_body(
|
||||
"5e5e5e5e6f6f6f6f7070707081818181",
|
||||
fields=(("prompt", "a cat"),),
|
||||
files=(("image", "a.png", mask), ("mask", "b.png", image)),
|
||||
),
|
||||
"5e5e5e5e6f6f6f6f7070707081818181",
|
||||
)
|
||||
assert swapped.status_code == REPLAY_MISS_STATUS
|
||||
|
||||
same = replay_upload(
|
||||
root,
|
||||
multipart_body(
|
||||
"9292929203030303a4a4a4a4b5b5b5b5",
|
||||
fields=(("prompt", "a cat"),),
|
||||
files=(("image", "a.png", image), ("mask", "b.png", mask)),
|
||||
),
|
||||
"9292929203030303a4a4a4a4b5b5b5b5",
|
||||
)
|
||||
assert same.status_code == 200, same.body[:400]
|
||||
|
||||
def test_a_body_that_does_not_match_its_declared_boundary_stays_opaque(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
root = tmp_path / "bundle"
|
||||
opaque = b"custom_id one\ncustom_id two\n"
|
||||
absent = "boundary-that-is-absent-from-the-body"
|
||||
record_upload(root, opaque, absent)
|
||||
|
||||
raw = this_tests_files(root)[0].read_text(encoding="utf-8")
|
||||
interaction = Interaction.model_validate_json(raw)
|
||||
assert interaction.request.form is None
|
||||
assert interaction.request.file_name == "<unparsed-multipart>"
|
||||
assert interaction.request.file_bytes == len(opaque)
|
||||
assert "custom_id" not in interaction.request.model_dump_json()
|
||||
assert replay_upload(root, opaque, absent).status_code == 200
|
||||
|
||||
|
||||
def raw_multipart(boundary: str, *parts: tuple[str, bytes]) -> bytes:
|
||||
"""A body assembled from literal part headers, so a test can send the shapes a
|
||||
well-formed helper cannot: a file part with no filename, a declared per-part content
|
||||
type, a repeated or bracketed field name, or a non-UTF-8 value."""
|
||||
return (
|
||||
b"".join(
|
||||
f"--{boundary}\r\n{head}\r\n\r\n".encode() + content + b"\r\n"
|
||||
for head, content in parts
|
||||
)
|
||||
+ f"--{boundary}--\r\n".encode()
|
||||
)
|
||||
|
||||
|
||||
def upload_key(body: bytes, boundary: str) -> str:
|
||||
content_type: Final = f"multipart/form-data; boundary={boundary}"
|
||||
return canonicalize(edge_request("POST", UPLOAD_PATH, "", body, content_type)).key
|
||||
|
||||
|
||||
DISPOSITION = 'Content-Disposition: form-data; name="{name}"'
|
||||
FILE_DISPOSITION = DISPOSITION + '; filename="{filename}"'
|
||||
|
||||
|
||||
class TestMultipartIdentityEdges:
|
||||
"""The identity a multipart upload keys on, pinned against the ways two materially
|
||||
different uploads could otherwise collapse onto one key. A collision here is the
|
||||
dangerous failure: replay would answer one request with another's response."""
|
||||
|
||||
def test_a_declared_part_content_type_separates_otherwise_identical_uploads(self) -> None:
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
as_json = raw_multipart(
|
||||
boundary,
|
||||
(FILE_DISPOSITION.format(name="file", filename="a") + "\r\nContent-Type: application/json", b"xy"),
|
||||
)
|
||||
as_csv = raw_multipart(
|
||||
boundary,
|
||||
(FILE_DISPOSITION.format(name="file", filename="a") + "\r\nContent-Type: text/csv", b"xy"),
|
||||
)
|
||||
|
||||
assert upload_key(as_json, boundary) != upload_key(as_csv, boundary)
|
||||
|
||||
def test_a_file_part_without_a_filename_is_not_mistaken_for_a_plain_field(self) -> None:
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
upload = raw_multipart(
|
||||
boundary,
|
||||
(DISPOSITION.format(name="file") + "\r\nContent-Type: application/octet-stream", b"CONTENT"),
|
||||
)
|
||||
plain_field = raw_multipart(boundary, (DISPOSITION.format(name="file"), b"CONTENT"))
|
||||
|
||||
request = edge_request(
|
||||
"POST", UPLOAD_PATH, "", upload, f"multipart/form-data; boundary={boundary}"
|
||||
)
|
||||
|
||||
assert upload_key(upload, boundary) != upload_key(plain_field, boundary)
|
||||
assert request.form == {}
|
||||
assert b"CONTENT".decode() not in request.model_dump_json()
|
||||
|
||||
def test_a_filename_carrying_a_per_run_marker_keys_the_same_next_run(self) -> None:
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
def upload(marker: str) -> str:
|
||||
body = raw_multipart(
|
||||
boundary,
|
||||
(FILE_DISPOSITION.format(name="one", filename=f"{marker}.jsonl"), b"first"),
|
||||
(FILE_DISPOSITION.format(name="two", filename="steady.jsonl"), b"second"),
|
||||
)
|
||||
return upload_key(body, boundary)
|
||||
|
||||
assert upload("a1b2c3d4e5f6") == upload("0f9e8d7c6b5a")
|
||||
|
||||
def test_a_separator_inside_a_filename_cannot_forge_a_different_split(self) -> None:
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
colon_in_filename = raw_multipart(
|
||||
boundary, (FILE_DISPOSITION.format(name="file", filename="a:b.jsonl"), b"same")
|
||||
)
|
||||
colon_in_field = raw_multipart(
|
||||
boundary, (FILE_DISPOSITION.format(name="file:a", filename="b.jsonl"), b"same")
|
||||
)
|
||||
|
||||
assert upload_key(colon_in_filename, boundary) != upload_key(colon_in_field, boundary)
|
||||
|
||||
def test_a_repeated_field_cannot_collide_with_a_literal_indexed_name(self) -> None:
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
repeated = raw_multipart(
|
||||
boundary,
|
||||
(DISPOSITION.format(name="purpose"), b"x"),
|
||||
(DISPOSITION.format(name="purpose"), b"y"),
|
||||
)
|
||||
literal_index = raw_multipart(
|
||||
boundary,
|
||||
(DISPOSITION.format(name="purpose"), b"x"),
|
||||
(DISPOSITION.format(name="purpose[1]"), b"y"),
|
||||
)
|
||||
|
||||
assert upload_key(repeated, boundary) != upload_key(literal_index, boundary)
|
||||
|
||||
def test_two_binary_field_values_of_one_length_stay_apart(self) -> None:
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
first = raw_multipart(boundary, (DISPOSITION.format(name="blob"), b"\xff\xfe\xfd"))
|
||||
second = raw_multipart(boundary, (DISPOSITION.format(name="blob"), b"\xf0\xf1\xf2"))
|
||||
|
||||
assert upload_key(first, boundary) != upload_key(second, boundary)
|
||||
|
||||
def test_a_secret_named_field_never_reaches_the_stored_request(self) -> None:
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
body = raw_multipart(
|
||||
boundary,
|
||||
(DISPOSITION.format(name="openai_api_key"), b"sk-live-DEADBEEF-0123456789abcd"),
|
||||
(DISPOSITION.format(name="purpose"), b"batch"),
|
||||
)
|
||||
|
||||
request = edge_request(
|
||||
"POST", UPLOAD_PATH, "", body, f"multipart/form-data; boundary={boundary}"
|
||||
)
|
||||
|
||||
assert "sk-live-DEADBEEF-0123456789abcd" not in request.model_dump_json()
|
||||
assert request.form == {"openai_api_key": "<secret>", "purpose": "batch"}
|
||||
|
||||
def test_a_redacted_field_still_matches_the_live_request_that_carried_the_secret(
|
||||
self,
|
||||
) -> None:
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
def upload(secret: str) -> str:
|
||||
body = raw_multipart(
|
||||
boundary,
|
||||
(DISPOSITION.format(name="openai_api_key"), secret.encode()),
|
||||
(DISPOSITION.format(name="purpose"), b"batch"),
|
||||
)
|
||||
return upload_key(body, boundary)
|
||||
|
||||
assert upload("sk-live-DEADBEEF-0123456789abcd") == upload("<secret>")
|
||||
|
||||
def test_a_length_change_the_canonicalizer_absorbs_does_not_move_the_key(self) -> None:
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
def upload(created: str) -> str:
|
||||
body = raw_multipart(
|
||||
boundary,
|
||||
(
|
||||
FILE_DISPOSITION.format(name="file", filename="batch.jsonl"),
|
||||
b'{"created_at":"' + created.encode() + b'"}',
|
||||
),
|
||||
)
|
||||
return upload_key(body, boundary)
|
||||
|
||||
assert upload("2026-08-21T02:08:19Z") == upload("2026-08-21T02:08:19.123456Z")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content_type",
|
||||
[
|
||||
pytest.param("multipart/form-data; myboundary=zzz; boundary={boundary}", id="lookalike-parameter"),
|
||||
pytest.param("multipart/form-data; BOUNDARY={boundary}", id="uppercase-parameter"),
|
||||
],
|
||||
)
|
||||
def test_the_boundary_parameter_is_read_the_way_the_client_meant_it(
|
||||
self, content_type: str
|
||||
) -> None:
|
||||
boundary = "0123456789abcdef0123456789abcdef"
|
||||
body = raw_multipart(
|
||||
boundary, (FILE_DISPOSITION.format(name="file", filename="batch.jsonl"), BATCH_JSONL)
|
||||
)
|
||||
|
||||
request = edge_request(
|
||||
"POST", UPLOAD_PATH, "", body, content_type.format(boundary=boundary)
|
||||
)
|
||||
|
||||
assert request.form == {}
|
||||
assert request.file_name is not None
|
||||
assert "batch.jsonl" in request.file_name
|
||||
|
||||
def test_an_empty_declared_boundary_falls_back_instead_of_splitting_on_dashes(self) -> None:
|
||||
body = b'--\r\nContent-Disposition: form-data; name="a"\r\n\r\nvalue\r\n----\r\n'
|
||||
|
||||
request = edge_request(
|
||||
"POST", UPLOAD_PATH, "", body, 'multipart/form-data; boundary=""'
|
||||
)
|
||||
|
||||
assert request.form is None
|
||||
assert request.file_sha256 is not None
|
||||
|
||||
|
||||
class TestReplayLeftover:
|
||||
def test_partially_consumed_recording_names_the_leftover(self, tmp_path: Path) -> None:
|
||||
root = tmp_path / "bundle"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -63,7 +63,7 @@ def test_container_files_api():
|
|||
|
||||
# 3. Try retrieve non-existent file metadata (should raise error)
|
||||
print("3. Testing retrieve_container_file (expect error)...")
|
||||
with pytest.raises(Exception, match="(?i)not found|invalid"):
|
||||
with pytest.raises(Exception, match=r"(?i)not found|invalid"):
|
||||
retrieve_container_file(
|
||||
container_id=container.id,
|
||||
file_id="cfile_nonexistent",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
|
@ -578,7 +579,7 @@ def test_litellm_gateway_from_sdk_with_response_cost_in_additional_headers():
|
|||
|
||||
|
||||
def test_litellm_gateway_from_sdk_with_thinking_param():
|
||||
with pytest.raises(Exception, match="Connection error.") as exc_info:
|
||||
with pytest.raises(Exception, match=re.escape("Connection error.")) as exc_info:
|
||||
response = litellm.completion(
|
||||
model="litellm_proxy/anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
messages=[{"role": "user", "content": "Hello world"}],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
|
|
@ -536,13 +537,19 @@ def test_demo_tokens_as_input_to_embeddings_fails_for_titan():
|
|||
|
||||
with pytest.raises(
|
||||
litellm.BadRequestError,
|
||||
match='litellm.BadRequestError: BedrockException - {"message":"Malformed input request: expected type: String, found: JSONArray, please reformat your input and try again."}',
|
||||
match=re.escape(
|
||||
'litellm.BadRequestError: BedrockException - {"message":"Malformed input request: '
|
||||
'expected type: String, found: JSONArray, please reformat your input and try again."}'
|
||||
),
|
||||
):
|
||||
litellm.embedding(model="amazon.titan-embed-text-v1", input=[[1]])
|
||||
|
||||
with pytest.raises(
|
||||
litellm.BadRequestError,
|
||||
match='litellm.BadRequestError: BedrockException - {"message":"Malformed input request: expected type: String, found: Integer, please reformat your input and try again."}',
|
||||
match=re.escape(
|
||||
'litellm.BadRequestError: BedrockException - {"message":"Malformed input request: '
|
||||
'expected type: String, found: Integer, please reformat your input and try again."}'
|
||||
),
|
||||
):
|
||||
litellm.embedding(
|
||||
model="amazon.titan-embed-text-v1",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
# This tests setting rules before / after making llm api calls
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
|
@ -82,7 +83,7 @@ def test_post_call_rule():
|
|||
litellm.post_call_rules = [my_post_call_rule]
|
||||
|
||||
### completion
|
||||
with pytest.raises(Exception, match="This violates LiteLLM Proxy Rules. Response too short") as exc_info:
|
||||
with pytest.raises(Exception, match=re.escape("This violates LiteLLM Proxy Rules. Response too short")) as exc_info:
|
||||
completion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "say sorry"}],
|
||||
|
|
@ -118,7 +119,7 @@ def test_post_call_rule_streaming():
|
|||
stream=True,
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="This violates LiteLLM Proxy Rules. Response too short") as exc_info:
|
||||
with pytest.raises(Exception, match=re.escape("This violates LiteLLM Proxy Rules. Response too short")) as exc_info:
|
||||
list(response)
|
||||
assert "This violates LiteLLM Proxy Rules. Response too short" in exc_info.value.message
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ async def test_team_blocking_behavior_multi_instance():
|
|||
assert team_info_4001["blocked"] is True, "Team should be blocked after update"
|
||||
|
||||
# 8. Make a chat completion request on port 4000 with a new prompt; expect it to be blocked.
|
||||
with pytest.raises(Exception, match="(?i)blocked") as excinfo:
|
||||
with pytest.raises(Exception, match=r"(?i)blocked") as excinfo:
|
||||
await chat_completion_on_port(
|
||||
session,
|
||||
key=key,
|
||||
|
|
@ -157,7 +157,7 @@ async def test_team_blocking_behavior_multi_instance():
|
|||
), f"Expected error indicating team blocked, got: {error_msg}"
|
||||
|
||||
# 9. Make a chat completion request on port 4000 with a new prompt; expect it to be blocked.
|
||||
with pytest.raises(Exception, match="(?i)blocked") as excinfo:
|
||||
with pytest.raises(Exception, match=r"(?i)blocked") as excinfo:
|
||||
await chat_completion_on_port(
|
||||
session,
|
||||
key=key,
|
||||
|
|
@ -171,7 +171,7 @@ async def test_team_blocking_behavior_multi_instance():
|
|||
), f"Expected error indicating team blocked, got: {error_msg}"
|
||||
|
||||
# 9. Repeat the chat completion request with another new prompt; expect it to be blocked.
|
||||
with pytest.raises(Exception, match="(?i)blocked") as excinfo_second:
|
||||
with pytest.raises(Exception, match=r"(?i)blocked") as excinfo_second:
|
||||
await chat_completion_on_port(
|
||||
session,
|
||||
key=key,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ RBAC tests
|
|||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -411,7 +412,7 @@ async def test_org_admin_create_user_team_wrong_org_permissions(prisma_client):
|
|||
request.body = return_body
|
||||
|
||||
with pytest.raises(
|
||||
Exception, match="You do not have a role within the selected organization. Passed organization_id"
|
||||
Exception, match=re.escape("You do not have a role within the selected organization. Passed organization_id")
|
||||
) as exc_info:
|
||||
response = await user_api_key_auth(request=request, api_key="Bearer " + new_key)
|
||||
e = exc_info.value
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
# function to validate a request - async def user_auth(request: Request):
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -1498,7 +1499,9 @@ def test_key_generate_with_custom_auth(prisma_client):
|
|||
await litellm.proxy.proxy_server.prisma_client.connect()
|
||||
request = GenerateKeyRequest()
|
||||
|
||||
with pytest.raises(Exception, match="This violates LiteLLM Proxy Rules. No team id provided.") as exc_info:
|
||||
with pytest.raises(
|
||||
Exception, match=re.escape("This violates LiteLLM Proxy Rules. No team id provided.")
|
||||
) as exc_info:
|
||||
key = await generate_key_fn(
|
||||
request,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
|
|
@ -3045,7 +3048,9 @@ async def test_custom_api_key_header_name(prisma_client):
|
|||
"headers": [],
|
||||
}
|
||||
)
|
||||
with pytest.raises(Exception, match="Malformed API Key passed in. Ensure Key has `Bearer ` prefix") as exc_info:
|
||||
with pytest.raises(
|
||||
Exception, match=re.escape("Malformed API Key passed in. Ensure Key has `Bearer ` prefix")
|
||||
) as exc_info:
|
||||
result = await user_api_key_auth(request=request, api_key="Bearer sk-1234")
|
||||
e = exc_info.value
|
||||
print("failed with error", e)
|
||||
|
|
|
|||
|
|
@ -1832,7 +1832,7 @@ def test_init_auto_router_deployment_duplicate_model_name(mock_auto_router, mode
|
|||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="Auto-router deployment test-auto-router with tags .* already exists"
|
||||
ValueError, match=r"Auto-router deployment test-auto-router with tags .* already exists"
|
||||
):
|
||||
router.init_auto_router_deployment(deployment)
|
||||
|
||||
|
|
|
|||
|
|
@ -800,6 +800,43 @@ async def test_output_file_content_vertex_unified_file_id_extracts_gcs_uri(monke
|
|||
assert captured["custom_llm_provider"] == "vertex_ai"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_file_content_model_encoded_file_id_decoded_to_provider_id(monkeypatch):
|
||||
import litellm.files.main as files_main
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import encode_file_id_with_model
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_afile_content(**kw):
|
||||
captured.update(kw)
|
||||
return type("R", (), {"content": b'{"a": 1}'})()
|
||||
|
||||
monkeypatch.setattr(files_main, "afile_content", fake_afile_content)
|
||||
encoded_id = encode_file_id_with_model("file-Y3FHrMpi7uCkDpY6fgWGeR", "my-batch-model")
|
||||
|
||||
await bu._fetch_batch_output_file_content(_batch(encoded_id), custom_llm_provider="openai")
|
||||
|
||||
assert captured["file_id"] == "file-Y3FHrMpi7uCkDpY6fgWGeR"
|
||||
assert captured["custom_llm_provider"] == "openai"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_file_content_raw_openai_file_id_passes_through(monkeypatch):
|
||||
import litellm.files.main as files_main
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_afile_content(**kw):
|
||||
captured.update(kw)
|
||||
return type("R", (), {"content": b'{"a": 1}'})()
|
||||
|
||||
monkeypatch.setattr(files_main, "afile_content", fake_afile_content)
|
||||
|
||||
await bu._fetch_batch_output_file_content(_batch("file-abc123"), custom_llm_provider="openai")
|
||||
|
||||
assert captured["file_id"] == "file-abc123"
|
||||
|
||||
|
||||
def _vertex_predictions_row(custom_id, prompt_tokens, completion_tokens):
|
||||
return {
|
||||
"request": {
|
||||
|
|
|
|||
|
|
@ -1508,7 +1508,7 @@ def test_multiple_tool_calls_in_single_choice():
|
|||
print("✓ Multiple tool calls are correctly grouped in a single choice")
|
||||
|
||||
|
||||
def test_map_reasoning_effort_adds_summary_detailed():
|
||||
def test_map_reasoning_effort_adds_summary_detailed(monkeypatch):
|
||||
"""
|
||||
Test that _map_reasoning_effort behavior with reasoning_auto_summary flag.
|
||||
|
||||
|
|
@ -1571,7 +1571,7 @@ def test_map_reasoning_effort_adds_summary_detailed():
|
|||
|
||||
# Test 3: With env var enabled (flag disabled) - summary IS added
|
||||
litellm.reasoning_auto_summary = False
|
||||
os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = "true"
|
||||
monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true")
|
||||
|
||||
result = handler._map_reasoning_effort("high")
|
||||
assert (
|
||||
|
|
@ -1603,7 +1603,7 @@ def test_map_reasoning_effort_adds_summary_detailed():
|
|||
# Restore original values
|
||||
litellm.reasoning_auto_summary = original_flag
|
||||
if original_env is not None:
|
||||
os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = original_env
|
||||
monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", original_env)
|
||||
elif "LITELLM_REASONING_AUTO_SUMMARY" in os.environ:
|
||||
del os.environ["LITELLM_REASONING_AUTO_SUMMARY"]
|
||||
|
||||
|
|
|
|||
|
|
@ -341,10 +341,10 @@ class TestOpenAIContainerTransformation:
|
|||
assert data["expires_after"] is None
|
||||
assert data["file_ids"] is None
|
||||
|
||||
def test_container_create_response_includes_cost(self):
|
||||
def test_container_create_response_includes_cost(self, monkeypatch):
|
||||
"""Test that container create response includes code interpreter cost calculation."""
|
||||
# Force use of local model cost map for CI/CD consistency
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
|
||||
|
|
|
|||
|
|
@ -88,49 +88,44 @@ async def test_send_email_success(mock_env_vars):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_missing_api_key():
|
||||
async def test_send_email_missing_api_key(monkeypatch):
|
||||
# Remove the API key from environment before initializing logger
|
||||
original_key = os.environ.pop("RESEND_API_KEY", None)
|
||||
monkeypatch.delenv("RESEND_API_KEY", raising=False)
|
||||
|
||||
try:
|
||||
# Initialize the logger after removing the API key
|
||||
logger = ResendEmailLogger()
|
||||
# Initialize the logger after removing the API key
|
||||
logger = ResendEmailLogger()
|
||||
|
||||
# Test data
|
||||
from_email = "test@example.com"
|
||||
to_email = ["recipient@example.com"]
|
||||
subject = "Test Subject"
|
||||
html_body = "<p>Test email body</p>"
|
||||
# Test data
|
||||
from_email = "test@example.com"
|
||||
to_email = ["recipient@example.com"]
|
||||
subject = "Test Subject"
|
||||
html_body = "<p>Test email body</p>"
|
||||
|
||||
# Create mock HTTP client and inject it directly into the logger
|
||||
# This ensures the mock is used regardless of any caching issues
|
||||
mock_response = mock.Mock(spec=Response)
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"id": "test_email_id"}
|
||||
# Create mock HTTP client and inject it directly into the logger
|
||||
# This ensures the mock is used regardless of any caching issues
|
||||
mock_response = mock.Mock(spec=Response)
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"id": "test_email_id"}
|
||||
|
||||
mock_async_client = mock.AsyncMock()
|
||||
mock_async_client.post.return_value = mock_response
|
||||
mock_async_client = mock.AsyncMock()
|
||||
mock_async_client.post.return_value = mock_response
|
||||
|
||||
# Directly inject the mock client to bypass any caching
|
||||
logger.async_httpx_client = mock_async_client
|
||||
# Directly inject the mock client to bypass any caching
|
||||
logger.async_httpx_client = mock_async_client
|
||||
|
||||
# Send email
|
||||
await logger.send_email(
|
||||
from_email=from_email,
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
html_body=html_body,
|
||||
)
|
||||
# Send email
|
||||
await logger.send_email(
|
||||
from_email=from_email,
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
html_body=html_body,
|
||||
)
|
||||
|
||||
# Verify the HTTP client was called with None as the API key
|
||||
mock_async_client.post.assert_called_once()
|
||||
call_args = mock_async_client.post.call_args
|
||||
assert call_args[1]["headers"] == {"Authorization": "Bearer None"}
|
||||
finally:
|
||||
# Restore the original key if it existed
|
||||
if original_key is not None:
|
||||
os.environ["RESEND_API_KEY"] = original_key
|
||||
# Verify the HTTP client was called with None as the API key
|
||||
mock_async_client.post.assert_called_once()
|
||||
call_args = mock_async_client.post.call_args
|
||||
assert call_args[1]["headers"] == {"Authorization": "Bearer None"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -98,22 +98,18 @@ async def test_send_email_success(mock_env_vars, mock_async_client):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_email_missing_api_key():
|
||||
original_key = os.environ.pop("SENDGRID_API_KEY", None)
|
||||
async def test_send_email_missing_api_key(monkeypatch):
|
||||
monkeypatch.delenv("SENDGRID_API_KEY", raising=False)
|
||||
|
||||
try:
|
||||
logger = SendGridEmailLogger()
|
||||
logger = SendGridEmailLogger()
|
||||
|
||||
with pytest.raises(ValueError, match='SENDGRID_API_KEY is not set'):
|
||||
await logger.send_email(
|
||||
from_email="test@example.com",
|
||||
to_email=["recipient@example.com"],
|
||||
subject="Test Subject",
|
||||
html_body="<p>Test email body</p>",
|
||||
)
|
||||
finally:
|
||||
if original_key is not None:
|
||||
os.environ["SENDGRID_API_KEY"] = original_key
|
||||
with pytest.raises(ValueError, match='SENDGRID_API_KEY is not set'):
|
||||
await logger.send_email(
|
||||
from_email="test@example.com",
|
||||
to_email=["recipient@example.com"],
|
||||
subject="Test Subject",
|
||||
html_body="<p>Test email body</p>",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -158,7 +159,7 @@ def test_bitbucket_client_get_file_content_access_denied(mock_get):
|
|||
|
||||
client = BitBucketClient(config)
|
||||
|
||||
with pytest.raises(Exception, match="Access denied to file 'test.prompt'"):
|
||||
with pytest.raises(Exception, match=re.escape("Access denied to file 'test.prompt'")):
|
||||
client.get_file_content("test.prompt")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import os
|
||||
import time
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
|
@ -12,34 +11,13 @@ from litellm.types.utils import StandardLoggingPayload
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env():
|
||||
# Save original env
|
||||
original_api_key = os.environ.get("DD_API_KEY")
|
||||
original_app_key = os.environ.get("DD_APP_KEY")
|
||||
original_site = os.environ.get("DD_SITE")
|
||||
|
||||
# Set test env
|
||||
os.environ["DD_API_KEY"] = "test_api_key"
|
||||
os.environ["DD_APP_KEY"] = "test_app_key"
|
||||
os.environ["DD_SITE"] = "test.datadoghq.com"
|
||||
|
||||
yield
|
||||
|
||||
# Restore original env
|
||||
if original_api_key:
|
||||
os.environ["DD_API_KEY"] = original_api_key
|
||||
else:
|
||||
del os.environ["DD_API_KEY"]
|
||||
|
||||
if original_app_key:
|
||||
os.environ["DD_APP_KEY"] = original_app_key
|
||||
else:
|
||||
del os.environ["DD_APP_KEY"]
|
||||
|
||||
if original_site:
|
||||
os.environ["DD_SITE"] = original_site
|
||||
else:
|
||||
del os.environ["DD_SITE"]
|
||||
def clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for key, value in (
|
||||
("DD_API_KEY", "test_api_key"),
|
||||
("DD_APP_KEY", "test_app_key"),
|
||||
("DD_SITE", "test.datadoghq.com"),
|
||||
):
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
|
@ -11,25 +10,16 @@ from litellm.types.utils import StandardLoggingPayload
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env():
|
||||
"""Set test env vars and restore originals after test."""
|
||||
keys = ["DD_API_KEY", "DD_APP_KEY", "DD_SITE", "DD_ENV", "DD_SERVICE", "DD_VERSION"]
|
||||
originals = {k: os.environ.get(k) for k in keys}
|
||||
|
||||
os.environ["DD_API_KEY"] = "test_api_key"
|
||||
os.environ["DD_APP_KEY"] = "test_app_key"
|
||||
os.environ["DD_SITE"] = "test.datadoghq.com"
|
||||
os.environ["DD_ENV"] = "test-env"
|
||||
os.environ["DD_SERVICE"] = "test-service"
|
||||
os.environ["DD_VERSION"] = "1.0.0"
|
||||
|
||||
yield
|
||||
|
||||
for k, v in originals.items():
|
||||
if v is not None:
|
||||
os.environ[k] = v
|
||||
elif k in os.environ:
|
||||
del os.environ[k]
|
||||
def clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for key, value in (
|
||||
("DD_API_KEY", "test_api_key"),
|
||||
("DD_APP_KEY", "test_app_key"),
|
||||
("DD_SITE", "test.datadoghq.com"),
|
||||
("DD_ENV", "test-env"),
|
||||
("DD_SERVICE", "test-service"),
|
||||
("DD_VERSION", "1.0.0"),
|
||||
):
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase
|
|||
|
||||
|
||||
class TestGCSBucketBase:
|
||||
def test_construct_request_headers_with_project_id(self):
|
||||
def test_construct_request_headers_with_project_id(self, monkeypatch):
|
||||
"""Test that construct_request_headers correctly uses project_id if passed from env"""
|
||||
test_project_id = "test-project"
|
||||
os.environ["GOOGLE_SECRET_MANAGER_PROJECT_ID"] = test_project_id
|
||||
monkeypatch.setenv("GOOGLE_SECRET_MANAGER_PROJECT_ID", test_project_id)
|
||||
|
||||
try:
|
||||
# Create handler
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
import re
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -172,7 +173,7 @@ def test_gitlab_client_get_file_content_access_denied(mock_get):
|
|||
mock_get.side_effect = err
|
||||
|
||||
client = GitLabClient({"project": "g/s/r", "access_token": "tok"})
|
||||
with pytest.raises(Exception, match="Access denied to file 'test.prompt'"):
|
||||
with pytest.raises(Exception, match=re.escape("Access denied to file 'test.prompt'")):
|
||||
client.get_file_content("test.prompt")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class TestOpenMeterIntegration:
|
|||
def test_openmeter_logger_missing_api_key(self):
|
||||
"""Test that OpenMeterLogger raises exception when API key is missing"""
|
||||
os.environ.pop("OPENMETER_API_KEY", None)
|
||||
with pytest.raises(Exception, match="Missing keys.*OPENMETER_API_KEY"):
|
||||
with pytest.raises(Exception, match=r"Missing keys.*OPENMETER_API_KEY"):
|
||||
OpenMeterLogger()
|
||||
|
||||
def test_common_logic_with_string_user(self):
|
||||
|
|
@ -236,9 +236,9 @@ class TestOpenMeterIntegration:
|
|||
assert result["data"]["completion_tokens"] == 8
|
||||
assert result["data"]["total_tokens"] == 23
|
||||
|
||||
def test_custom_event_type(self):
|
||||
def test_custom_event_type(self, monkeypatch):
|
||||
"""Test that custom event type is used when set"""
|
||||
os.environ["OPENMETER_EVENT_TYPE"] = "custom_event_type"
|
||||
monkeypatch.setenv("OPENMETER_EVENT_TYPE", "custom_event_type")
|
||||
|
||||
logger = OpenMeterLogger()
|
||||
|
||||
|
|
@ -374,10 +374,10 @@ class TestOpenMeterIntegration:
|
|||
assert isinstance(result["subject"], str)
|
||||
assert result["subject"] == "12345"
|
||||
|
||||
def test_common_logic_trust_request_user_false_ignores_request_user(self):
|
||||
def test_common_logic_trust_request_user_false_ignores_request_user(self, monkeypatch):
|
||||
"""OPENMETER_TRUST_REQUEST_USER=false makes the key-bound user_id win
|
||||
over a request-supplied `user` (forge-attribution mitigation)."""
|
||||
os.environ["OPENMETER_TRUST_REQUEST_USER"] = "false"
|
||||
monkeypatch.setenv("OPENMETER_TRUST_REQUEST_USER", "false")
|
||||
logger = OpenMeterLogger()
|
||||
|
||||
kwargs = {
|
||||
|
|
@ -400,11 +400,11 @@ class TestOpenMeterIntegration:
|
|||
assert result["subject"] == "real-tenant-id"
|
||||
assert result["subject"] != "forged-by-client"
|
||||
|
||||
def test_common_logic_trust_request_user_false_still_raises_without_key_user(self):
|
||||
def test_common_logic_trust_request_user_false_still_raises_without_key_user(self, monkeypatch):
|
||||
"""OPENMETER_TRUST_REQUEST_USER=false still raises when no
|
||||
user_api_key_user_id is available — the request `user` is not a
|
||||
fallback in this mode."""
|
||||
os.environ["OPENMETER_TRUST_REQUEST_USER"] = "false"
|
||||
monkeypatch.setenv("OPENMETER_TRUST_REQUEST_USER", "false")
|
||||
logger = OpenMeterLogger()
|
||||
|
||||
kwargs = {
|
||||
|
|
|
|||
|
|
@ -751,7 +751,7 @@ async def test_strip_base64_mixed_nested_objects():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_verify_false_handling():
|
||||
async def test_s3_verify_false_handling(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that s3_verify=False is properly handled and not treated as None.
|
||||
|
||||
|
|
@ -763,15 +763,19 @@ async def test_s3_verify_false_handling():
|
|||
import litellm
|
||||
|
||||
# Set up s3_callback_params with s3_verify=False
|
||||
litellm.s3_callback_params = {
|
||||
"s3_bucket_name": "test-bucket",
|
||||
"s3_endpoint_url": "https://localhost:443",
|
||||
"s3_aws_access_key_id": "minioadmin",
|
||||
"s3_aws_secret_access_key": "minioadmin",
|
||||
"s3_region_name": "us-east-1",
|
||||
"s3_verify": False, # This should NOT be ignored
|
||||
"s3_use_ssl": False, # This should also NOT be ignored
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"s3_callback_params",
|
||||
{
|
||||
"s3_bucket_name": "test-bucket",
|
||||
"s3_endpoint_url": "https://localhost:443",
|
||||
"s3_aws_access_key_id": "minioadmin",
|
||||
"s3_aws_secret_access_key": "minioadmin",
|
||||
"s3_region_name": "us-east-1",
|
||||
"s3_verify": False, # This should NOT be ignored
|
||||
"s3_use_ssl": False, # This should also NOT be ignored
|
||||
},
|
||||
)
|
||||
|
||||
with patch("asyncio.create_task"):
|
||||
with patch(
|
||||
|
|
@ -801,12 +805,9 @@ async def test_s3_verify_false_handling():
|
|||
"ssl_verify": False
|
||||
}, f"Expected ssl_verify=False in params, got {call_kwargs.get('params')}"
|
||||
|
||||
# Clean up
|
||||
litellm.s3_callback_params = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_verify_none_handling():
|
||||
async def test_s3_verify_none_handling(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that s3_verify=None uses default behavior.
|
||||
"""
|
||||
|
|
@ -815,12 +816,16 @@ async def test_s3_verify_none_handling():
|
|||
import litellm
|
||||
|
||||
# Set up s3_callback_params without s3_verify
|
||||
litellm.s3_callback_params = {
|
||||
"s3_bucket_name": "test-bucket",
|
||||
"s3_aws_access_key_id": "test-key",
|
||||
"s3_aws_secret_access_key": "test-secret",
|
||||
"s3_region_name": "us-east-1",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"s3_callback_params",
|
||||
{
|
||||
"s3_bucket_name": "test-bucket",
|
||||
"s3_aws_access_key_id": "test-key",
|
||||
"s3_aws_secret_access_key": "test-secret",
|
||||
"s3_region_name": "us-east-1",
|
||||
},
|
||||
)
|
||||
|
||||
with patch("asyncio.create_task"):
|
||||
with patch(
|
||||
|
|
@ -846,12 +851,9 @@ async def test_s3_verify_none_handling():
|
|||
assert call_kwargs["params"].get("ssl_verify") is None
|
||||
# Either params is None or params={'ssl_verify': None} is acceptable
|
||||
|
||||
# Clean up
|
||||
litellm.s3_callback_params = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_verify_false_creates_httpx_client_with_verify_false():
|
||||
async def test_s3_verify_false_creates_httpx_client_with_verify_false(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that when s3_verify=False, the actual httpx client has verify=False.
|
||||
|
||||
|
|
@ -862,14 +864,18 @@ async def test_s3_verify_false_creates_httpx_client_with_verify_false():
|
|||
import litellm
|
||||
|
||||
# Set up s3_callback_params with s3_verify=False
|
||||
litellm.s3_callback_params = {
|
||||
"s3_bucket_name": "test-bucket",
|
||||
"s3_endpoint_url": "https://localhost:443",
|
||||
"s3_aws_access_key_id": "minioadmin",
|
||||
"s3_aws_secret_access_key": "minioadmin",
|
||||
"s3_region_name": "us-east-1",
|
||||
"s3_verify": False,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"s3_callback_params",
|
||||
{
|
||||
"s3_bucket_name": "test-bucket",
|
||||
"s3_endpoint_url": "https://localhost:443",
|
||||
"s3_aws_access_key_id": "minioadmin",
|
||||
"s3_aws_secret_access_key": "minioadmin",
|
||||
"s3_region_name": "us-east-1",
|
||||
"s3_verify": False,
|
||||
},
|
||||
)
|
||||
|
||||
with patch("asyncio.create_task"):
|
||||
# Create logger - this creates the httpx client
|
||||
|
|
@ -888,12 +894,9 @@ async def test_s3_verify_false_creates_httpx_client_with_verify_false():
|
|||
httpx_client._verify is False
|
||||
), f"Expected httpx client _verify=False, got {httpx_client._verify}"
|
||||
|
||||
# Clean up
|
||||
litellm.s3_callback_params = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_verify_false_async_client():
|
||||
async def test_s3_verify_false_async_client(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that the async httpx client respects s3_verify=False.
|
||||
"""
|
||||
|
|
@ -903,14 +906,18 @@ async def test_s3_verify_false_async_client():
|
|||
from litellm.types.integrations.s3_v2 import s3BatchLoggingElement
|
||||
|
||||
# Set up s3_callback_params with s3_verify=False
|
||||
litellm.s3_callback_params = {
|
||||
"s3_bucket_name": "test-bucket",
|
||||
"s3_endpoint_url": "https://localhost:443",
|
||||
"s3_aws_access_key_id": "minioadmin",
|
||||
"s3_aws_secret_access_key": "minioadmin",
|
||||
"s3_region_name": "us-east-1",
|
||||
"s3_verify": False,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"s3_callback_params",
|
||||
{
|
||||
"s3_bucket_name": "test-bucket",
|
||||
"s3_endpoint_url": "https://localhost:443",
|
||||
"s3_aws_access_key_id": "minioadmin",
|
||||
"s3_aws_secret_access_key": "minioadmin",
|
||||
"s3_region_name": "us-east-1",
|
||||
"s3_verify": False,
|
||||
},
|
||||
)
|
||||
|
||||
with patch("asyncio.create_task"):
|
||||
logger = S3Logger()
|
||||
|
|
@ -945,9 +952,6 @@ async def test_s3_verify_false_async_client():
|
|||
httpx_client._verify is False
|
||||
), f"Expected async httpx client _verify=False, got {httpx_client._verify}"
|
||||
|
||||
# Clean up
|
||||
litellm.s3_callback_params = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strip_base64_recursive_redaction():
|
||||
|
|
@ -1169,26 +1173,22 @@ def test_create_s3_batch_logging_element_flat_key_for_arn_response_id():
|
|||
# --------------------------------------------------------------
|
||||
# params_source / s3_callback_params_override (audit-log decoupling)
|
||||
# --------------------------------------------------------------
|
||||
def test_s3_callback_params_override_uses_alternate_dict():
|
||||
def test_s3_callback_params_override_uses_alternate_dict(monkeypatch):
|
||||
"""`s3_callback_params_override` makes the logger read its config from
|
||||
the override dict instead of `litellm.s3_callback_params`."""
|
||||
import litellm
|
||||
|
||||
original = litellm.s3_callback_params
|
||||
litellm.s3_callback_params = {"s3_bucket_name": "normal-bucket"}
|
||||
try:
|
||||
logger = S3Logger(
|
||||
s3_callback_params_override={
|
||||
"s3_bucket_name": "audit-bucket",
|
||||
"s3_path": "audit-prefix",
|
||||
"s3_region_name": "us-west-2",
|
||||
}
|
||||
)
|
||||
assert logger.s3_bucket_name == "audit-bucket"
|
||||
assert logger.s3_path == "audit-prefix"
|
||||
assert logger.s3_region_name == "us-west-2"
|
||||
finally:
|
||||
litellm.s3_callback_params = original
|
||||
monkeypatch.setattr(litellm, "s3_callback_params", {"s3_bucket_name": "normal-bucket"})
|
||||
logger = S3Logger(
|
||||
s3_callback_params_override={
|
||||
"s3_bucket_name": "audit-bucket",
|
||||
"s3_path": "audit-prefix",
|
||||
"s3_region_name": "us-west-2",
|
||||
}
|
||||
)
|
||||
assert logger.s3_bucket_name == "audit-bucket"
|
||||
assert logger.s3_path == "audit-prefix"
|
||||
assert logger.s3_region_name == "us-west-2"
|
||||
|
||||
|
||||
def test_s3_callback_params_override_does_not_mutate_inputs(monkeypatch):
|
||||
|
|
@ -1198,43 +1198,31 @@ def test_s3_callback_params_override_does_not_mutate_inputs(monkeypatch):
|
|||
|
||||
monkeypatch.setenv("MY_AUDIT_BUCKET", "resolved-bucket")
|
||||
override = {"s3_bucket_name": "os.environ/MY_AUDIT_BUCKET"}
|
||||
original_global = litellm.s3_callback_params
|
||||
litellm.s3_callback_params = {"s3_bucket_name": "os.environ/MY_AUDIT_BUCKET"}
|
||||
try:
|
||||
logger = S3Logger(s3_callback_params_override=override)
|
||||
assert logger.s3_bucket_name == "resolved-bucket"
|
||||
assert override["s3_bucket_name"] == "os.environ/MY_AUDIT_BUCKET"
|
||||
assert (
|
||||
litellm.s3_callback_params["s3_bucket_name"] == "os.environ/MY_AUDIT_BUCKET"
|
||||
)
|
||||
finally:
|
||||
litellm.s3_callback_params = original_global
|
||||
monkeypatch.setattr(litellm, "s3_callback_params", {"s3_bucket_name": "os.environ/MY_AUDIT_BUCKET"})
|
||||
logger = S3Logger(s3_callback_params_override=override)
|
||||
assert logger.s3_bucket_name == "resolved-bucket"
|
||||
assert override["s3_bucket_name"] == "os.environ/MY_AUDIT_BUCKET"
|
||||
assert (
|
||||
litellm.s3_callback_params["s3_bucket_name"] == "os.environ/MY_AUDIT_BUCKET"
|
||||
)
|
||||
|
||||
|
||||
def test_s3_callback_params_override_none_falls_back_to_global():
|
||||
def test_s3_callback_params_override_none_falls_back_to_global(monkeypatch):
|
||||
"""No override → behaves exactly as today (reads `litellm.s3_callback_params`)."""
|
||||
import litellm
|
||||
|
||||
original = litellm.s3_callback_params
|
||||
litellm.s3_callback_params = {"s3_bucket_name": "from-global"}
|
||||
try:
|
||||
logger = S3Logger()
|
||||
assert logger.s3_bucket_name == "from-global"
|
||||
finally:
|
||||
litellm.s3_callback_params = original
|
||||
monkeypatch.setattr(litellm, "s3_callback_params", {"s3_bucket_name": "from-global"})
|
||||
logger = S3Logger()
|
||||
assert logger.s3_bucket_name == "from-global"
|
||||
|
||||
|
||||
def test_s3_callback_params_override_empty_dict_is_opt_in():
|
||||
def test_s3_callback_params_override_empty_dict_is_opt_in(monkeypatch):
|
||||
"""An empty override dict skips the global entirely (env/IAM-only config)."""
|
||||
import litellm
|
||||
|
||||
original = litellm.s3_callback_params
|
||||
litellm.s3_callback_params = {"s3_bucket_name": "from-global"}
|
||||
try:
|
||||
logger = S3Logger(s3_callback_params_override={})
|
||||
assert logger.s3_bucket_name is None
|
||||
finally:
|
||||
litellm.s3_callback_params = original
|
||||
monkeypatch.setattr(litellm, "s3_callback_params", {"s3_bucket_name": "from-global"})
|
||||
logger = S3Logger(s3_callback_params_override={})
|
||||
assert logger.s3_bucket_name is None
|
||||
|
||||
|
||||
def _expected_content_md5(payload: dict) -> str:
|
||||
|
|
@ -1374,20 +1362,20 @@ async def test_async_upload_sets_server_side_encryption_header_when_configured()
|
|||
assert headers["x-amz-server-side-encryption"] == "aws:kms"
|
||||
|
||||
|
||||
def test_s3_server_side_encryption_read_from_callback_params():
|
||||
def test_s3_server_side_encryption_read_from_callback_params(monkeypatch):
|
||||
"""s3_server_side_encryption can be configured via s3_callback_params."""
|
||||
import litellm
|
||||
|
||||
original = litellm.s3_callback_params
|
||||
litellm.s3_callback_params = {
|
||||
"s3_bucket_name": "from-global",
|
||||
"s3_server_side_encryption": "aws:kms",
|
||||
}
|
||||
try:
|
||||
logger = S3Logger()
|
||||
assert logger.s3_server_side_encryption == "aws:kms"
|
||||
finally:
|
||||
litellm.s3_callback_params = original
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"s3_callback_params",
|
||||
{
|
||||
"s3_bucket_name": "from-global",
|
||||
"s3_server_side_encryption": "aws:kms",
|
||||
},
|
||||
)
|
||||
logger = S3Logger()
|
||||
assert logger.s3_server_side_encryption == "aws:kms"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1505,21 +1493,21 @@ async def test_async_upload_omits_kms_key_id_header_when_not_configured():
|
|||
assert "x-amz-server-side-encryption-aws-kms-key-id" not in headers
|
||||
|
||||
|
||||
def test_s3_sse_kms_key_id_read_from_callback_params():
|
||||
def test_s3_sse_kms_key_id_read_from_callback_params(monkeypatch):
|
||||
"""s3_sse_kms_key_id can be configured via s3_callback_params."""
|
||||
import litellm
|
||||
|
||||
original = litellm.s3_callback_params
|
||||
litellm.s3_callback_params = {
|
||||
"s3_bucket_name": "from-global",
|
||||
"s3_server_side_encryption": "aws:kms",
|
||||
"s3_sse_kms_key_id": "arn:aws:kms:us-east-1:111122223333:key/test-key-id",
|
||||
}
|
||||
try:
|
||||
logger = S3Logger()
|
||||
assert logger.s3_sse_kms_key_id == ("arn:aws:kms:us-east-1:111122223333:key/test-key-id")
|
||||
finally:
|
||||
litellm.s3_callback_params = original
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"s3_callback_params",
|
||||
{
|
||||
"s3_bucket_name": "from-global",
|
||||
"s3_server_side_encryption": "aws:kms",
|
||||
"s3_sse_kms_key_id": "arn:aws:kms:us-east-1:111122223333:key/test-key-id",
|
||||
},
|
||||
)
|
||||
logger = S3Logger()
|
||||
assert logger.s3_sse_kms_key_id == ("arn:aws:kms:us-east-1:111122223333:key/test-key-id")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1561,83 +1549,79 @@ async def test_async_upload_infers_aws_kms_when_only_key_id_set():
|
|||
)
|
||||
|
||||
|
||||
def test_s3_sse_kms_key_id_read_from_audit_override_params():
|
||||
def test_s3_sse_kms_key_id_read_from_audit_override_params(monkeypatch):
|
||||
"""The audit-log override path must honor s3_sse_kms_key_id too."""
|
||||
import litellm
|
||||
|
||||
original = litellm.s3_callback_params
|
||||
litellm.s3_callback_params = {"s3_bucket_name": "normal-logs-bucket"}
|
||||
try:
|
||||
logger = S3Logger(
|
||||
s3_callback_params_override={
|
||||
"s3_bucket_name": "audit-logs-bucket",
|
||||
"s3_sse_kms_key_id": "arn:aws:kms:us-east-1:111122223333:key/audit-key-id",
|
||||
}
|
||||
)
|
||||
assert logger.s3_bucket_name == "audit-logs-bucket"
|
||||
assert logger.s3_sse_kms_key_id == ("arn:aws:kms:us-east-1:111122223333:key/audit-key-id")
|
||||
finally:
|
||||
litellm.s3_callback_params = original
|
||||
monkeypatch.setattr(litellm, "s3_callback_params", {"s3_bucket_name": "normal-logs-bucket"})
|
||||
logger = S3Logger(
|
||||
s3_callback_params_override={
|
||||
"s3_bucket_name": "audit-logs-bucket",
|
||||
"s3_sse_kms_key_id": "arn:aws:kms:us-east-1:111122223333:key/audit-key-id",
|
||||
}
|
||||
)
|
||||
assert logger.s3_bucket_name == "audit-logs-bucket"
|
||||
assert logger.s3_sse_kms_key_id == ("arn:aws:kms:us-east-1:111122223333:key/audit-key-id")
|
||||
|
||||
|
||||
def test_kms_key_id_dropped_when_algorithm_is_not_kms():
|
||||
def test_kms_key_id_dropped_when_algorithm_is_not_kms(monkeypatch):
|
||||
"""
|
||||
AES256 plus a KMS key id is an invalid S3 combination; the key id must be
|
||||
dropped at init so uploads keep working instead of silently 400ing.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
original = litellm.s3_callback_params
|
||||
litellm.s3_callback_params = {
|
||||
"s3_bucket_name": "from-global",
|
||||
"s3_server_side_encryption": "AES256",
|
||||
"s3_sse_kms_key_id": "arn:aws:kms:us-east-1:111122223333:key/test-key-id",
|
||||
}
|
||||
try:
|
||||
logger = S3Logger()
|
||||
assert logger.s3_server_side_encryption == "AES256"
|
||||
assert logger.s3_sse_kms_key_id is None
|
||||
finally:
|
||||
litellm.s3_callback_params = original
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"s3_callback_params",
|
||||
{
|
||||
"s3_bucket_name": "from-global",
|
||||
"s3_server_side_encryption": "AES256",
|
||||
"s3_sse_kms_key_id": "arn:aws:kms:us-east-1:111122223333:key/test-key-id",
|
||||
},
|
||||
)
|
||||
logger = S3Logger()
|
||||
assert logger.s3_server_side_encryption == "AES256"
|
||||
assert logger.s3_sse_kms_key_id is None
|
||||
|
||||
|
||||
def test_non_string_algorithm_is_dropped_and_valid_key_id_is_rescued():
|
||||
def test_non_string_algorithm_is_dropped_and_valid_key_id_is_rescued(monkeypatch):
|
||||
"""
|
||||
A YAML boolean in s3_server_side_encryption must not crash logger init and
|
||||
must not discard the valid key id; aws:kms is inferred from the key id.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
original = litellm.s3_callback_params
|
||||
litellm.s3_callback_params = {
|
||||
"s3_bucket_name": "from-global",
|
||||
"s3_server_side_encryption": True,
|
||||
"s3_sse_kms_key_id": "arn:aws:kms:us-east-1:111122223333:key/test-key-id",
|
||||
}
|
||||
try:
|
||||
logger = S3Logger()
|
||||
assert logger.s3_server_side_encryption == "aws:kms"
|
||||
assert logger.s3_sse_kms_key_id == ("arn:aws:kms:us-east-1:111122223333:key/test-key-id")
|
||||
finally:
|
||||
litellm.s3_callback_params = original
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"s3_callback_params",
|
||||
{
|
||||
"s3_bucket_name": "from-global",
|
||||
"s3_server_side_encryption": True,
|
||||
"s3_sse_kms_key_id": "arn:aws:kms:us-east-1:111122223333:key/test-key-id",
|
||||
},
|
||||
)
|
||||
logger = S3Logger()
|
||||
assert logger.s3_server_side_encryption == "aws:kms"
|
||||
assert logger.s3_sse_kms_key_id == ("arn:aws:kms:us-east-1:111122223333:key/test-key-id")
|
||||
|
||||
|
||||
def test_non_string_key_id_is_dropped_and_valid_algorithm_is_kept():
|
||||
def test_non_string_key_id_is_dropped_and_valid_algorithm_is_kept(monkeypatch):
|
||||
"""A mistyped key id (unquoted YAML number) must not disable the valid algorithm."""
|
||||
import litellm
|
||||
|
||||
original = litellm.s3_callback_params
|
||||
litellm.s3_callback_params = {
|
||||
"s3_bucket_name": "from-global",
|
||||
"s3_server_side_encryption": "aws:kms",
|
||||
"s3_sse_kms_key_id": 12345,
|
||||
}
|
||||
try:
|
||||
logger = S3Logger()
|
||||
assert logger.s3_server_side_encryption == "aws:kms"
|
||||
assert logger.s3_sse_kms_key_id is None
|
||||
finally:
|
||||
litellm.s3_callback_params = original
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"s3_callback_params",
|
||||
{
|
||||
"s3_bucket_name": "from-global",
|
||||
"s3_server_side_encryption": "aws:kms",
|
||||
"s3_sse_kms_key_id": 12345,
|
||||
},
|
||||
)
|
||||
logger = S3Logger()
|
||||
assert logger.s3_server_side_encryption == "aws:kms"
|
||||
assert logger.s3_sse_kms_key_id is None
|
||||
|
||||
|
||||
_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"
|
||||
|
|
|
|||
|
|
@ -86,29 +86,21 @@ class TestValidateEnvironment:
|
|||
assert headers["X-Custom"] == "value"
|
||||
assert headers["x-goog-api-key"] == "test-key"
|
||||
|
||||
def test_api_revision_new_schema_by_default(self, config):
|
||||
def test_api_revision_new_schema_by_default(self, config, monkeypatch: pytest.MonkeyPatch):
|
||||
# Default: use_legacy_interactions_schema=False → new steps schema
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = False
|
||||
headers = config.validate_environment(
|
||||
headers={}, model="gemini-2.5-flash", litellm_params=None
|
||||
)
|
||||
assert headers["Api-Revision"] == "2026-05-20"
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
monkeypatch.setattr(litellm, "use_legacy_interactions_schema", False)
|
||||
headers = config.validate_environment(
|
||||
headers={}, model="gemini-2.5-flash", litellm_params=None
|
||||
)
|
||||
assert headers["Api-Revision"] == "2026-05-20"
|
||||
|
||||
def test_api_revision_legacy_schema_when_flag_set(self, config):
|
||||
def test_api_revision_legacy_schema_when_flag_set(self, config, monkeypatch: pytest.MonkeyPatch):
|
||||
# Flag on → legacy outputs schema until June 8, 2026
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = True
|
||||
headers = config.validate_environment(
|
||||
headers={}, model="gemini-2.5-flash", litellm_params=None
|
||||
)
|
||||
assert headers["Api-Revision"] == "2026-05-07"
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
monkeypatch.setattr(litellm, "use_legacy_interactions_schema", True)
|
||||
headers = config.validate_environment(
|
||||
headers={}, model="gemini-2.5-flash", litellm_params=None
|
||||
)
|
||||
assert headers["Api-Revision"] == "2026-05-07"
|
||||
|
||||
|
||||
class TestGetCompleteUrl:
|
||||
|
|
@ -561,23 +553,19 @@ class TestInteractionOperationUrls:
|
|||
class TestTransformRequestSchemaCoalescing:
|
||||
"""Test new-schema request coalescing (Api-Revision: 2026-05-20)."""
|
||||
|
||||
def test_response_mime_type_folded_into_response_format(self, config):
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = False
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="summarise",
|
||||
optional_params={
|
||||
"response_mime_type": "application/json",
|
||||
"response_format": {"type": "object", "properties": {}},
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
def test_response_mime_type_folded_into_response_format(self, config, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "use_legacy_interactions_schema", False)
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="summarise",
|
||||
optional_params={
|
||||
"response_mime_type": "application/json",
|
||||
"response_format": {"type": "object", "properties": {}},
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
# response_mime_type must not appear as a top-level body key
|
||||
assert "response_mime_type" not in body
|
||||
|
|
@ -586,25 +574,21 @@ class TestTransformRequestSchemaCoalescing:
|
|||
assert rf["mime_type"] == "application/json"
|
||||
assert "schema" in rf
|
||||
|
||||
def test_image_config_moved_to_response_format(self, config):
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = False
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="draw a sunset",
|
||||
optional_params={
|
||||
"generation_config": {
|
||||
"temperature": 0.7,
|
||||
"image_config": {"aspect_ratio": "1:1", "image_size": "1K"},
|
||||
}
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
def test_image_config_moved_to_response_format(self, config, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "use_legacy_interactions_schema", False)
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="draw a sunset",
|
||||
optional_params={
|
||||
"generation_config": {
|
||||
"temperature": 0.7,
|
||||
"image_config": {"aspect_ratio": "1:1", "image_size": "1K"},
|
||||
}
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
# image_config removed from generation_config
|
||||
assert "image_config" not in body.get("generation_config", {})
|
||||
|
|
@ -613,95 +597,85 @@ class TestTransformRequestSchemaCoalescing:
|
|||
assert rf["type"] == "image"
|
||||
assert rf["aspect_ratio"] == "1:1"
|
||||
|
||||
def test_response_mime_type_skipped_when_response_format_is_list(self, config):
|
||||
def test_response_mime_type_skipped_when_response_format_is_list(self, config, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Lists are already polymorphic; do not wrap them into schema."""
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = False
|
||||
rf_list = [
|
||||
{"type": "text", "mime_type": "application/json"},
|
||||
{"type": "image", "aspect_ratio": "1:1"},
|
||||
]
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="multimodal",
|
||||
optional_params={
|
||||
"response_format": rf_list,
|
||||
"response_mime_type": "application/json",
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
monkeypatch.setattr(litellm, "use_legacy_interactions_schema", False)
|
||||
rf_list = [
|
||||
{"type": "text", "mime_type": "application/json"},
|
||||
{"type": "image", "aspect_ratio": "1:1"},
|
||||
]
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="multimodal",
|
||||
optional_params={
|
||||
"response_format": rf_list,
|
||||
"response_mime_type": "application/json",
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert body["response_format"] == rf_list
|
||||
assert "response_mime_type" not in body
|
||||
|
||||
def test_image_config_appended_to_response_format_list_without_mutating_input(
|
||||
self, config
|
||||
self,
|
||||
config,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""When response_format is already a list, image_config must not mutate optional_params."""
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = False
|
||||
text_rf = {"type": "text", "mime_type": "application/json"}
|
||||
optional_params = {
|
||||
"response_format": [text_rf],
|
||||
"generation_config": {
|
||||
"image_config": {"aspect_ratio": "16:9", "image_size": "2K"},
|
||||
},
|
||||
}
|
||||
original_rf = optional_params["response_format"]
|
||||
monkeypatch.setattr(litellm, "use_legacy_interactions_schema", False)
|
||||
text_rf = {"type": "text", "mime_type": "application/json"}
|
||||
optional_params = {
|
||||
"response_format": [text_rf],
|
||||
"generation_config": {
|
||||
"image_config": {"aspect_ratio": "16:9", "image_size": "2K"},
|
||||
},
|
||||
}
|
||||
original_rf = optional_params["response_format"]
|
||||
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="draw and summarise",
|
||||
optional_params=optional_params,
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="draw and summarise",
|
||||
optional_params=optional_params,
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert optional_params["response_format"] is original_rf
|
||||
assert len(optional_params["response_format"]) == 1
|
||||
assert body["response_format"] == [
|
||||
text_rf,
|
||||
{"type": "image", "aspect_ratio": "16:9", "image_size": "2K"},
|
||||
]
|
||||
assert optional_params["response_format"] is original_rf
|
||||
assert len(optional_params["response_format"]) == 1
|
||||
assert body["response_format"] == [
|
||||
text_rf,
|
||||
{"type": "image", "aspect_ratio": "16:9", "image_size": "2K"},
|
||||
]
|
||||
|
||||
# Retry must not append a second image entry into the caller's list.
|
||||
body_retry = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="draw and summarise",
|
||||
optional_params=optional_params,
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
assert len(optional_params["response_format"]) == 1
|
||||
assert body_retry["response_format"] == body["response_format"]
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
# Retry must not append a second image entry into the caller's list.
|
||||
body_retry = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="draw and summarise",
|
||||
optional_params=optional_params,
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
assert len(optional_params["response_format"]) == 1
|
||||
assert body_retry["response_format"] == body["response_format"]
|
||||
|
||||
def test_legacy_schema_passes_fields_unchanged(self, config):
|
||||
original = litellm.use_legacy_interactions_schema
|
||||
try:
|
||||
litellm.use_legacy_interactions_schema = True
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="hello",
|
||||
optional_params={
|
||||
"response_mime_type": "application/json",
|
||||
"generation_config": {"image_config": {"aspect_ratio": "16:9"}},
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
finally:
|
||||
litellm.use_legacy_interactions_schema = original
|
||||
def test_legacy_schema_passes_fields_unchanged(self, config, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "use_legacy_interactions_schema", True)
|
||||
body = config.transform_request(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
agent=None,
|
||||
input="hello",
|
||||
optional_params={
|
||||
"response_mime_type": "application/json",
|
||||
"generation_config": {"image_config": {"aspect_ratio": "16:9"}},
|
||||
},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert body["response_mime_type"] == "application/json"
|
||||
assert body["generation_config"]["image_config"]["aspect_ratio"] == "16:9"
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ def test_bedrock_guardrail_cost_no_pricing_entry(monkeypatch):
|
|||
assert bedrock_guardrail_cost(usage_units={"contentPolicyUnits": 1}, aws_region_name="us-east-1") == 0.0
|
||||
|
||||
|
||||
def test_shipped_bedrock_guardrail_prices_match_aws_pricing_page():
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
def test_shipped_bedrock_guardrail_prices_match_aws_pricing_page(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
assert litellm.model_cost["bedrock/guardrails"]["guardrail_cost_per_unit"] == {
|
||||
"automatedReasoningPolicyUnits": 0.00017,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -28,10 +26,6 @@ from litellm.types.utils import (
|
|||
StandardBuiltInToolsParams,
|
||||
)
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
PromptTokensDetailsResult,
|
||||
TokenTypeCostBreakdown,
|
||||
|
|
@ -44,13 +38,17 @@ from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
|||
from litellm.types.utils import CacheCreationTokenDetails, Usage
|
||||
|
||||
|
||||
def test_reasoning_tokens_no_price_set():
|
||||
@pytest.fixture
|
||||
def _local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
|
||||
|
||||
def test_reasoning_tokens_no_price_set(_local_model_cost_map):
|
||||
# Use o1 - o1-mini was deprecated/renamed; o1 has same reasoning-token semantics
|
||||
# (no separate output_cost_per_reasoning_token, so all completion tokens use output_cost_per_token)
|
||||
model = "o1"
|
||||
custom_llm_provider = "openai"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
usage = Usage(
|
||||
completion_tokens=1578,
|
||||
|
|
@ -87,11 +85,9 @@ def test_reasoning_tokens_no_price_set():
|
|||
)
|
||||
|
||||
|
||||
def test_reasoning_tokens_gemini():
|
||||
def test_reasoning_tokens_gemini(_local_model_cost_map):
|
||||
model = "gemini-2.5-flash"
|
||||
custom_llm_provider = "gemini"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
usage = Usage(
|
||||
completion_tokens=1578,
|
||||
|
|
@ -132,12 +128,10 @@ def test_reasoning_tokens_gemini():
|
|||
)
|
||||
|
||||
|
||||
def test_reasoning_tokens_gemini_3_1_flash_lite():
|
||||
def test_reasoning_tokens_gemini_3_1_flash_lite(_local_model_cost_map):
|
||||
"""Test cost calculation for gemini-3.1-flash-lite-preview with reasoning tokens"""
|
||||
model = "gemini-3.1-flash-lite-preview"
|
||||
custom_llm_provider = "gemini"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
usage = Usage(
|
||||
completion_tokens=1000,
|
||||
|
|
@ -270,11 +264,9 @@ def test_image_tokens_fallback_to_base_cost():
|
|||
assert round(completion_cost, 12) == round(expected_completion_cost, 12)
|
||||
|
||||
|
||||
def test_video_output_tokens_gemini_omni_flash_preview():
|
||||
def test_video_output_tokens_gemini_omni_flash_preview(_local_model_cost_map):
|
||||
"""Video output tokens are billed at output_cost_per_video_token, not the text rate and not zero."""
|
||||
model = "gemini-omni-flash-preview"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
text_tokens = 100
|
||||
video_tokens = 46336
|
||||
|
|
@ -310,11 +302,9 @@ def test_video_output_tokens_gemini_omni_flash_preview():
|
|||
)
|
||||
|
||||
|
||||
def test_video_input_tokens_gemini_omni_flash_preview():
|
||||
def test_video_input_tokens_gemini_omni_flash_preview(_local_model_cost_map):
|
||||
"""Video input tokens are billed at the standard input rate instead of being dropped."""
|
||||
model = "gemini-omni-flash-preview"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
usage = Usage(
|
||||
completion_tokens=10,
|
||||
|
|
@ -369,12 +359,10 @@ def test_video_tokens_fallback_to_base_cost():
|
|||
assert round(completion_cost, 12) == round((600 + 1120) * 2e-6, 12)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_above_200k_tokens():
|
||||
def test_generic_cost_per_token_above_200k_tokens(_local_model_cost_map):
|
||||
# gemini-2.5-pro-exp-03-25 was removed; gemini-2.5-pro has same above-200k pricing
|
||||
model = "gemini-2.5-pro"
|
||||
custom_llm_provider = "vertex_ai"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
prompt_tokens = 220 * 1e6
|
||||
|
|
@ -420,12 +408,10 @@ def test_get_token_base_cost_picks_highest_crossed_tier():
|
|||
assert prompt_base_cost == 9e-6
|
||||
|
||||
|
||||
def test_generic_cost_per_token_gpt54_above_272k_tokens():
|
||||
def test_generic_cost_per_token_gpt54_above_272k_tokens(_local_model_cost_map):
|
||||
"""GPT-5.4/5.4-pro: prompts >272K input tokens priced at 2x input, 1.5x output."""
|
||||
model = "gpt-5.4"
|
||||
custom_llm_provider = "openai"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
prompt_tokens = 273000 # Above 272K threshold
|
||||
|
|
@ -450,12 +436,10 @@ def test_generic_cost_per_token_gpt54_above_272k_tokens():
|
|||
assert round(completion_cost, 10) == round(expected_completion, 10)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_minimax_m3_above_512k_tokens():
|
||||
def test_generic_cost_per_token_minimax_m3_above_512k_tokens(_local_model_cost_map):
|
||||
"""MiniMax-M3: prompts >512K input tokens priced at 2x input, output, and cache read."""
|
||||
model = "minimax/MiniMax-M3"
|
||||
custom_llm_provider = "minimax"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
prompt_tokens = 600000
|
||||
|
|
@ -493,10 +477,8 @@ def test_generic_cost_per_token_minimax_m3_above_512k_tokens():
|
|||
"bedrock_mantle/openai.gpt-5.6-luna",
|
||||
],
|
||||
)
|
||||
def test_generic_cost_per_token_bedrock_mantle_gpt56_long_context(model):
|
||||
def test_generic_cost_per_token_bedrock_mantle_gpt56_long_context(_local_model_cost_map, model):
|
||||
"""Bedrock GPT-5.6 supports a 1M context window, billed at the long-context rates above 272K."""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
assert model_cost_map["max_input_tokens"] == 1000000
|
||||
|
|
@ -827,12 +809,10 @@ def test_generic_cost_per_token_tiered_pricing_bills_reasoning_at_tier_rate():
|
|||
litellm.model_cost.pop(model, None)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_gpt55():
|
||||
def test_generic_cost_per_token_gpt55(_local_model_cost_map):
|
||||
"""gpt-5.5: base pricing — $5/1M input, $30/1M output, $0.50/1M cached input."""
|
||||
model = "gpt-5.5"
|
||||
custom_llm_provider = "openai"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
|
||||
|
|
@ -867,12 +847,10 @@ def test_generic_cost_per_token_gpt55():
|
|||
)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_gpt55_pro():
|
||||
def test_generic_cost_per_token_gpt55_pro(_local_model_cost_map):
|
||||
"""gpt-5.5-pro: responses-only model — $30/1M input, $180/1M output, $3/1M cached input."""
|
||||
model = "gpt-5.5-pro"
|
||||
custom_llm_provider = "openai"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
|
||||
|
|
@ -913,13 +891,13 @@ def test_generic_cost_per_token_gpt55_pro():
|
|||
@pytest.mark.parametrize(
|
||||
"model,input_cost,output_cost,cache_read_cost,cache_write_cost",
|
||||
[
|
||||
("gpt-5.6", 5e-6, 3e-5, 5e-7, 6.25e-6),
|
||||
("gpt-5.6-sol", 5e-6, 3e-5, 5e-7, 6.25e-6),
|
||||
("gpt-5.6", 4e-6, 2e-5, 4e-7, 5e-6),
|
||||
("gpt-5.6-sol", 4e-6, 2e-5, 4e-7, 5e-6),
|
||||
("gpt-5.6-terra", 2e-6, 1.2e-5, 2e-7, 2.5e-6),
|
||||
("gpt-5.6-luna", 2e-7, 1.2e-6, 2e-8, 2.5e-7),
|
||||
],
|
||||
)
|
||||
def test_generic_cost_per_token_gpt56(
|
||||
def test_generic_cost_per_token_gpt56(_local_model_cost_map,
|
||||
model, input_cost, output_cost, cache_read_cost, cache_write_cost
|
||||
):
|
||||
"""gpt-5.6 (sol/terra/luna): base pricing + new cache-write cost.
|
||||
|
|
@ -927,8 +905,6 @@ def test_generic_cost_per_token_gpt56(
|
|||
Cache writes are billed at 1.25x the uncached input rate for this family.
|
||||
"""
|
||||
custom_llm_provider = "openai"
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
|
||||
|
|
@ -965,16 +941,31 @@ def test_generic_cost_per_token_gpt56(
|
|||
assert round(completion_cost, 10) == round(output_cost * completion_tokens, 10)
|
||||
|
||||
|
||||
def test_gpt_5_6_alias_prices_match_sol(local_model_cost_map):
|
||||
"""Regression: the bare gpt-5.6 alias routes to GPT-5.6 Sol, so every cost field on
|
||||
the two entries has to hold the same value. They drifted once before, when Sol took
|
||||
its promotional cut and gpt-5.6 was left on the pre-cut rates, overbilling callers
|
||||
who used the alias."""
|
||||
alias = litellm.model_cost["gpt-5.6"]
|
||||
sol = litellm.model_cost["gpt-5.6-sol"]
|
||||
|
||||
cost_fields = sorted(field for field in sol if "cost" in field)
|
||||
assert len(cost_fields) == 23
|
||||
|
||||
for field in cost_fields:
|
||||
assert alias.get(field) == sol.get(field), field
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,flex_long_input_cost,flex_long_output_cost",
|
||||
[
|
||||
("gpt-5.6", 5e-6, 2.25e-5),
|
||||
("gpt-5.6-sol", 5e-6, 2.25e-5),
|
||||
("gpt-5.6", 4e-6, 1.5e-5),
|
||||
("gpt-5.6-sol", 4e-6, 1.5e-5),
|
||||
("gpt-5.6-terra", 2e-6, 9e-6),
|
||||
("gpt-5.6-luna", 2e-7, 9e-7),
|
||||
],
|
||||
)
|
||||
def test_generic_cost_per_token_gpt56_flex_above_272k(
|
||||
def test_generic_cost_per_token_gpt56_flex_above_272k(_local_model_cost_map,
|
||||
model, flex_long_input_cost, flex_long_output_cost
|
||||
):
|
||||
"""A >272K flex request bills the flex long-context rate, not the standard one.
|
||||
|
|
@ -983,8 +974,6 @@ def test_generic_cost_per_token_gpt56_flex_above_272k(
|
|||
``*_above_272k_tokens_flex`` keys these requests silently fell back to the
|
||||
standard long-context price, billing 2x what OpenAI charges.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
prompt_tokens = 300000
|
||||
completion_tokens = 1000
|
||||
|
|
@ -1023,11 +1012,9 @@ def test_generic_cost_per_token_gpt56_flex_above_272k(
|
|||
("flex", 300000, 2e-6, 2.5e-6, 2e-7),
|
||||
],
|
||||
)
|
||||
def test_generic_cost_per_token_gpt56_terra_cache_costs_by_tier_and_context(
|
||||
def test_generic_cost_per_token_gpt56_terra_cache_costs_by_tier_and_context(_local_model_cost_map,
|
||||
service_tier, prompt_tokens, input_rate, cache_write_rate, cache_read_rate
|
||||
):
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
cached_tokens = 50000
|
||||
cache_write_tokens = 40000
|
||||
|
|
@ -1115,14 +1102,14 @@ def test_generic_cost_per_token_gpt56_cyber(
|
|||
("azure/eu/gpt-5.6-luna", 2.2e-7, 1.32e-6, 2.2e-8),
|
||||
],
|
||||
)
|
||||
def test_generic_cost_per_token_azure_gpt56(
|
||||
def test_generic_cost_per_token_azure_gpt56(_local_model_cost_map,
|
||||
model, input_cost, output_cost, cache_read_cost
|
||||
):
|
||||
"""Azure gpt-5.6 (global + us/eu regional): pricing mirrors the openai
|
||||
family for global deployments and carries the standard 10% regional uplift.
|
||||
"""Azure gpt-5.6 (global + us/eu regional): Azure prices this family on its own
|
||||
schedule and carries the standard 10% regional uplift on top. It did not take the
|
||||
promotional cut OpenAI applied to gpt-5.6-sol, so these rates deliberately sit
|
||||
above the openai ones and must not be lowered to match them.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
assert model_cost_map["litellm_provider"] == "azure"
|
||||
|
|
@ -1163,7 +1150,7 @@ def test_generic_cost_per_token_azure_gpt56(
|
|||
("gpt-5.5-pro-2026-04-23", False, True, False),
|
||||
],
|
||||
)
|
||||
def test_gpt55_reasoning_effort_flags_match_live_openai_api(
|
||||
def test_gpt55_reasoning_effort_flags_match_live_openai_api(_local_model_cost_map,
|
||||
model, expected_none, expected_xhigh, expected_minimal
|
||||
):
|
||||
"""Pin reasoning_effort capability flags to OpenAI's actual API contract.
|
||||
|
|
@ -1172,8 +1159,6 @@ def test_gpt55_reasoning_effort_flags_match_live_openai_api(
|
|||
``Unsupported value: 'reasoning_effort' does not support 'minimal' with
|
||||
this model``. gpt-5.5-pro additionally rejects 'none' and 'low'.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
m = litellm.model_cost[model]
|
||||
assert (
|
||||
|
|
@ -1194,7 +1179,7 @@ def test_gpt55_reasoning_effort_flags_match_live_openai_api(
|
|||
("gpt-5.5-pro", "gpt-5.5-pro-2026-04-23"),
|
||||
],
|
||||
)
|
||||
def test_gpt55_dated_variants_match_base_reasoning_effort_capabilities(
|
||||
def test_gpt55_dated_variants_match_base_reasoning_effort_capabilities(_local_model_cost_map,
|
||||
base_model, dated_model
|
||||
):
|
||||
"""Dated snapshots must carry the same reasoning_effort capability flags as
|
||||
|
|
@ -1206,8 +1191,6 @@ def test_gpt55_dated_variants_match_base_reasoning_effort_capabilities(
|
|||
behavior between ``gpt-5.5`` and ``gpt-5.5-2026-04-23``. Pinning to a
|
||||
dated variant must never lose capabilities relative to the base alias.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
base = litellm.model_cost[base_model]
|
||||
dated = litellm.model_cost[dated_model]
|
||||
|
|
@ -1234,7 +1217,7 @@ def test_gpt55_dated_variants_match_base_reasoning_effort_capabilities(
|
|||
("azure/gpt-5.5-pro-2026-04-23", "responses", 3e-5, 1.8e-4, 3e-6),
|
||||
],
|
||||
)
|
||||
def test_azure_gpt55_entries_present_with_correct_pricing(
|
||||
def test_azure_gpt55_entries_present_with_correct_pricing(_local_model_cost_map,
|
||||
model, expected_mode, expected_input, expected_output, expected_cache_read
|
||||
):
|
||||
"""Day-0 Azure entries for GPT-5.5 mirror the OpenAI pricing structure.
|
||||
|
|
@ -1243,8 +1226,6 @@ def test_azure_gpt55_entries_present_with_correct_pricing(
|
|||
on 2026-04-24): $5/$30 input/output per 1M for chat, $30/$180 for pro.
|
||||
Cache discount is 10% of input.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
m = litellm.model_cost[model]
|
||||
assert m["litellm_provider"] == "azure"
|
||||
|
|
@ -1269,12 +1250,10 @@ def test_azure_gpt55_entries_present_with_correct_pricing(
|
|||
("azure/gpt-5.5-pro", False, False, True),
|
||||
],
|
||||
)
|
||||
def test_azure_gpt55_reasoning_effort_flags_match_live_openai_api(
|
||||
def test_azure_gpt55_reasoning_effort_flags_match_live_openai_api(_local_model_cost_map,
|
||||
model, expected_none, expected_minimal, expected_xhigh
|
||||
):
|
||||
"""Azure entries pin reasoning_effort flags to OpenAI's actual API contract."""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
m = litellm.model_cost[model]
|
||||
assert m.get("supports_none_reasoning_effort") is expected_none
|
||||
|
|
@ -1654,11 +1633,9 @@ def test_cache_writing_cost_with_zero_creation_tokens_and_ephemeral_details():
|
|||
assert round(result, 6) == round(expected, 6)
|
||||
|
||||
|
||||
def test_service_tier_flex_pricing():
|
||||
def test_service_tier_flex_pricing(_local_model_cost_map):
|
||||
"""Test that flex service tier uses correct pricing (approximately 50% of standard)."""
|
||||
# Set up environment for local model cost map
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
# Test with gpt-5-nano which has flex pricing
|
||||
model = "gpt-5-nano"
|
||||
|
|
@ -1711,11 +1688,9 @@ def test_service_tier_flex_pricing():
|
|||
), f"Flex total cost mismatch: {flex_total} vs {expected_flex_total}"
|
||||
|
||||
|
||||
def test_service_tier_default_pricing():
|
||||
def test_service_tier_default_pricing(_local_model_cost_map):
|
||||
"""Test that when no service tier is provided, standard pricing is used."""
|
||||
# Set up environment for local model cost map
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
# Test with gpt-5-nano
|
||||
model = "gpt-5-nano"
|
||||
|
|
@ -1762,11 +1737,9 @@ def test_service_tier_default_pricing():
|
|||
), f"Standard completion cost mismatch: {default_cost[1]} vs {expected_standard_completion}"
|
||||
|
||||
|
||||
def test_service_tier_fallback_pricing():
|
||||
def test_service_tier_fallback_pricing(_local_model_cost_map):
|
||||
"""Test that when service tier is provided but model doesn't have those keys, it falls back to standard pricing."""
|
||||
# Set up environment for local model cost map
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
# Test with gpt-4 which doesn't have flex pricing keys
|
||||
model = "gpt-4"
|
||||
|
|
@ -1874,15 +1847,13 @@ def test_service_tier_ultrafast_pricing():
|
|||
assert completion_cost == pytest.approx(400 * 3e-04)
|
||||
|
||||
|
||||
def test_service_tier_ultrafast_fallback_pricing():
|
||||
def test_service_tier_ultrafast_fallback_pricing(_local_model_cost_map):
|
||||
"""Without *_ultrafast keys an ultrafast request bills the standard rate, not zero.
|
||||
|
||||
Guards the suffix fallback in _get_cost_per_unit: "_fast" is a substring of
|
||||
"_ultrafast", so a shortest-first suffix match would strip the wrong suffix
|
||||
and price the request at 0.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
usage = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500)
|
||||
|
||||
|
|
@ -1909,9 +1880,10 @@ def test_service_tier_ultrafast_fallback_pricing():
|
|||
[
|
||||
"gemini-3-pro-image-preview",
|
||||
"gemini-3.1-flash-image-preview",
|
||||
"gemini-3.1-flash-lite-image",
|
||||
],
|
||||
)
|
||||
def test_gemini_image_generation_cost_with_zero_text_tokens(model: str):
|
||||
def test_gemini_image_generation_cost_with_zero_text_tokens(_local_model_cost_map, model: str):
|
||||
"""
|
||||
Test that image_tokens are correctly costed when text_tokens=0.
|
||||
|
||||
|
|
@ -1921,8 +1893,6 @@ def test_gemini_image_generation_cost_with_zero_text_tokens(model: str):
|
|||
|
||||
https://github.com/BerriAI/litellm/issues/17410
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
custom_llm_provider = "vertex_ai"
|
||||
|
||||
|
|
@ -1977,13 +1947,11 @@ def test_gemini_image_generation_cost_with_zero_text_tokens(model: str):
|
|||
), f"Expected completion cost ${expected_completion_cost:.6f}, got ${completion_cost:.6f}"
|
||||
|
||||
|
||||
def test_vertex_image_generation_cost_prefers_token_usage_metadata():
|
||||
def test_vertex_image_generation_cost_prefers_token_usage_metadata(_local_model_cost_map):
|
||||
"""
|
||||
When usage metadata exists on image responses, Vertex image generation cost
|
||||
should be calculated from token pricing, not flat output_cost_per_image.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "gemini-3.1-flash-image-preview"
|
||||
model_info = litellm.get_model_info(model=model, custom_llm_provider="vertex_ai")
|
||||
|
|
@ -2022,13 +1990,11 @@ def test_vertex_image_generation_cost_prefers_token_usage_metadata():
|
|||
assert cost != len(image_response.data) * model_info["output_cost_per_image"]
|
||||
|
||||
|
||||
def test_vertex_image_generation_cost_falls_back_to_flat_image_pricing():
|
||||
def test_vertex_image_generation_cost_falls_back_to_flat_image_pricing(_local_model_cost_map):
|
||||
"""
|
||||
Without usage metadata, Vertex image generation cost should fall back to
|
||||
output_cost_per_image * number_of_images.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "gemini-3.1-flash-image-preview"
|
||||
model_info = litellm.get_model_info(model=model, custom_llm_provider="vertex_ai")
|
||||
|
|
@ -2046,13 +2012,11 @@ def test_vertex_image_generation_cost_falls_back_to_flat_image_pricing():
|
|||
assert round(cost, 10) == round(expected_cost, 10)
|
||||
|
||||
|
||||
def test_gemini_image_generation_cost_prefers_token_usage_metadata():
|
||||
def test_gemini_image_generation_cost_prefers_token_usage_metadata(_local_model_cost_map):
|
||||
"""
|
||||
When usage metadata exists on image responses, Gemini image generation cost
|
||||
should be calculated from token pricing, not flat output_cost_per_image.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "gemini/gemini-3-pro-image-preview"
|
||||
model_info = litellm.get_model_info(model=model, custom_llm_provider="gemini")
|
||||
|
|
@ -2091,13 +2055,11 @@ def test_gemini_image_generation_cost_prefers_token_usage_metadata():
|
|||
assert cost != len(image_response.data) * model_info["output_cost_per_image"]
|
||||
|
||||
|
||||
def test_gemini_image_generation_cost_falls_back_to_flat_image_pricing():
|
||||
def test_gemini_image_generation_cost_falls_back_to_flat_image_pricing(_local_model_cost_map):
|
||||
"""
|
||||
Without usage metadata, Gemini image generation cost should fall back to
|
||||
output_cost_per_image * number_of_images.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "gemini/gemini-3-pro-image-preview"
|
||||
model_info = litellm.get_model_info(model=model, custom_llm_provider="gemini")
|
||||
|
|
@ -2194,7 +2156,7 @@ def test_reasoning_tokens_without_text_tokens_gpt5_nano():
|
|||
), "Bug detected: Cost calculation is using only reasoning_tokens instead of all completion_tokens!"
|
||||
|
||||
|
||||
def test_image_count_prevents_text_tokens_fallback():
|
||||
def test_image_count_prevents_text_tokens_fallback(_local_model_cost_map):
|
||||
"""
|
||||
Test that the text_tokens fallback in generic_cost_per_token does not
|
||||
override text_tokens=0 when image_count > 0.
|
||||
|
|
@ -2203,8 +2165,6 @@ def test_image_count_prevents_text_tokens_fallback():
|
|||
When image_count > 0, text_tokens=0 is intentional (image-only request),
|
||||
not "text_tokens not set by provider."
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
# Simulate Nova image-only embedding: prompt_tokens estimated from
|
||||
# embedding dimensions (768 for 3072-dim), image_count=1
|
||||
|
|
@ -2238,20 +2198,6 @@ def test_image_count_prevents_text_tokens_fallback():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _local_model_cost_map():
|
||||
prev_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
|
||||
prev_model_cost = litellm.model_cost
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
litellm.model_cost = prev_model_cost
|
||||
if prev_env is None:
|
||||
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
|
||||
else:
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = prev_env
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["gpt-5.4", "gpt-realtime-2.1", "gpt-realtime-2.1-mini"])
|
||||
|
|
@ -2585,7 +2531,7 @@ def test_threshold_keys_exclude_service_tier_variants():
|
|||
("cerebras/qwen-3-32b", "cerebras", 250, 0),
|
||||
],
|
||||
)
|
||||
def test_token_type_cost_breakdown_is_provider_agnostic(
|
||||
def test_token_type_cost_breakdown_is_provider_agnostic(_local_model_cost_map,
|
||||
model, custom_llm_provider, reasoning_tokens, cached_tokens
|
||||
):
|
||||
"""
|
||||
|
|
@ -2597,8 +2543,6 @@ def test_token_type_cost_breakdown_is_provider_agnostic(
|
|||
there - not the top-level cache_read_input_tokens attribute the old breakdown code
|
||||
relied on - is what makes Vertex/OpenAI/Azure cache costs show up at all.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=1000,
|
||||
|
|
@ -2629,10 +2573,8 @@ def test_token_type_cost_breakdown_is_provider_agnostic(
|
|||
assert breakdown.cache_read_cost == pytest.approx(cached_tokens * cache_read_rate)
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_matches_real_gemini_numbers():
|
||||
def test_token_type_cost_breakdown_matches_real_gemini_numbers(_local_model_cost_map):
|
||||
"""Hard-coded against the exact gemini-2.5-flash response that exposed the gap."""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=209,
|
||||
|
|
@ -2655,9 +2597,7 @@ def test_token_type_cost_breakdown_matches_real_gemini_numbers():
|
|||
assert breakdown.cache_creation_cost == 0.0
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_xai_at_exactly_200k_uses_higher_tier_rates():
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
def test_token_type_cost_breakdown_xai_at_exactly_200k_uses_higher_tier_rates(_local_model_cost_map):
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=200_000,
|
||||
|
|
@ -2679,9 +2619,7 @@ def test_token_type_cost_breakdown_xai_at_exactly_200k_uses_higher_tier_rates():
|
|||
assert breakdown.cache_read_cost == pytest.approx(50_000 * 4e-07)
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_xai_just_below_200k_uses_base_tier_rates():
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
def test_token_type_cost_breakdown_xai_just_below_200k_uses_base_tier_rates(_local_model_cost_map):
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=199_999,
|
||||
|
|
@ -2703,14 +2641,12 @@ def test_token_type_cost_breakdown_xai_just_below_200k_uses_base_tier_rates():
|
|||
assert breakdown.cache_read_cost == pytest.approx(50_000 * 2e-07)
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_includes_cache_creation_from_top_level_usage():
|
||||
def test_token_type_cost_breakdown_includes_cache_creation_from_top_level_usage(_local_model_cost_map):
|
||||
"""
|
||||
Bedrock/Anthropic report cache tokens as top-level usage fields; the Usage
|
||||
constructor maps them onto prompt_tokens_details, so the breakdown must still
|
||||
pick up both cache-read and cache-creation costs.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "anthropic.claude-3-5-haiku-20241022-v1:0"
|
||||
usage = Usage(
|
||||
|
|
@ -2734,14 +2670,12 @@ def test_token_type_cost_breakdown_includes_cache_creation_from_top_level_usage(
|
|||
)
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_reads_cache_write_tokens():
|
||||
def test_token_type_cost_breakdown_reads_cache_write_tokens(_local_model_cost_map):
|
||||
"""
|
||||
Some OpenAI-compatible providers (e.g. kimi-k2) report cache-write tokens under
|
||||
`cache_write_tokens` rather than `cache_creation_tokens`. The breakdown must read
|
||||
it the same way the total-cost normalization does, so the two agree.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "anthropic.claude-3-5-haiku-20241022-v1:0"
|
||||
usage = Usage(
|
||||
|
|
@ -2762,7 +2696,7 @@ def test_token_type_cost_breakdown_reads_cache_write_tokens():
|
|||
)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_openai_cache_write_tokens_gpt_5_6():
|
||||
def test_generic_cost_per_token_openai_cache_write_tokens_gpt_5_6(_local_model_cost_map):
|
||||
"""
|
||||
Regression: OpenAI gpt-5.6 reports cache-write tokens under
|
||||
prompt_tokens_details.cache_write_tokens (not the Anthropic cache_creation_tokens
|
||||
|
|
@ -2770,8 +2704,6 @@ def test_generic_cost_per_token_openai_cache_write_tokens_gpt_5_6():
|
|||
input rate. Customer report: cache creation tokens were never counted for the
|
||||
GPT-5.6 series, so cost was undercounted on cache-write requests.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "gpt-5.6"
|
||||
usage = Usage(
|
||||
|
|
@ -2793,14 +2725,12 @@ def test_generic_cost_per_token_openai_cache_write_tokens_gpt_5_6():
|
|||
assert prompt_cost > 1000 * info["input_cost_per_token"]
|
||||
|
||||
|
||||
def test_generic_cost_per_token_backs_out_cache_write_tokens_from_text_tokens():
|
||||
def test_generic_cost_per_token_backs_out_cache_write_tokens_from_text_tokens(_local_model_cost_map):
|
||||
"""
|
||||
Regression for #34801: when a provider reports text_tokens covering the whole
|
||||
prompt alongside cache-write tokens (and no cache reads), the cache-write tokens
|
||||
must be backed out of the text total instead of being billed twice.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "gpt-5.6"
|
||||
usage = Usage(
|
||||
|
|
@ -2819,15 +2749,13 @@ def test_generic_cost_per_token_backs_out_cache_write_tokens_from_text_tokens():
|
|||
assert prompt_cost == pytest.approx(expected_prompt)
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_reconciles_with_generic_total():
|
||||
def test_token_type_cost_breakdown_reconciles_with_generic_total(_local_model_cost_map):
|
||||
"""
|
||||
Both-ways check: the reasoning subset must sum with the remaining (text) output
|
||||
cost to exactly the completion total, and the cache-read subset with the remaining
|
||||
input cost to exactly the prompt total, as computed by generic_cost_per_token.
|
||||
A mismatch here would mean the breakdown misrepresents what was actually billed.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "gemini-2.5-flash"
|
||||
custom_llm_provider = "vertex_ai"
|
||||
|
|
@ -2860,9 +2788,7 @@ def test_token_type_cost_breakdown_reconciles_with_generic_total():
|
|||
assert text_input_cost + breakdown.cache_read_cost == pytest.approx(prompt_cost)
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_zero_without_special_tokens():
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
def test_token_type_cost_breakdown_zero_without_special_tokens(_local_model_cost_map):
|
||||
|
||||
usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150)
|
||||
breakdown = get_token_type_cost_breakdown(
|
||||
|
|
@ -2899,7 +2825,7 @@ def test_token_type_cost_breakdown_zero_without_special_tokens():
|
|||
),
|
||||
],
|
||||
)
|
||||
def test_token_type_cost_breakdown_openai_responses_api_cache_write_read(
|
||||
def test_token_type_cost_breakdown_openai_responses_api_cache_write_read(_local_model_cost_map,
|
||||
raw_usage, expect_read, expect_write
|
||||
):
|
||||
"""Regression for #34309: OpenAI Responses API reports cache tokens under
|
||||
|
|
@ -2908,8 +2834,6 @@ def test_token_type_cost_breakdown_openai_responses_api_cache_write_read(
|
|||
cache_read_cost / cache_creation_cost from the transformed usage."""
|
||||
from litellm.responses.utils import ResponseAPILoggingUtils
|
||||
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "gpt-5.6"
|
||||
usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(raw_usage)
|
||||
|
|
@ -2950,15 +2874,13 @@ def test_token_type_cost_breakdown_handles_unknown_model_gracefully():
|
|||
)
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_applies_regional_uplift():
|
||||
def test_token_type_cost_breakdown_applies_regional_uplift(_local_model_cost_map):
|
||||
"""
|
||||
Regional OpenAI hosts (eu./us.) apply a flat uplift to every token cost. The
|
||||
per-type breakdown must apply the same uplift via data_residency so it stays
|
||||
reconciled with the uplifted input_cost/output_cost totals, instead of being
|
||||
logged at the base rate.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "gpt-5.4"
|
||||
custom_llm_provider = "openai"
|
||||
|
|
@ -3006,15 +2928,13 @@ def test_token_type_cost_breakdown_applies_regional_uplift():
|
|||
assert text_input_cost + eu.cache_read_cost == pytest.approx(prompt_cost)
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_applies_vertex_regional_uplift():
|
||||
def test_token_type_cost_breakdown_applies_vertex_regional_uplift(_local_model_cost_map):
|
||||
"""
|
||||
Non-global Vertex endpoints apply a flat 1.1x uplift to every token cost. The
|
||||
per-type breakdown must apply the same uplift via vertex_location so it stays
|
||||
reconciled with the uplifted input_cost/output_cost totals, instead of being
|
||||
logged at the global rate.
|
||||
"""
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "claude-haiku-4-5@20251001"
|
||||
custom_llm_provider = "vertex_ai"
|
||||
|
|
@ -3057,7 +2977,7 @@ def test_token_type_cost_breakdown_applies_vertex_regional_uplift():
|
|||
assert text_input_cost + regional.cache_read_cost == pytest.approx(prompt_cost)
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_applies_anthropic_geo_multiplier(monkeypatch):
|
||||
def test_token_type_cost_breakdown_applies_anthropic_geo_multiplier(_local_model_cost_map, monkeypatch):
|
||||
"""
|
||||
Anthropic's regional (geo) uplift lives in provider_specific_entry and is
|
||||
applied to every token type in the totals, so the per-type breakdown must
|
||||
|
|
@ -3070,7 +2990,6 @@ def test_token_type_cost_breakdown_applies_anthropic_geo_multiplier(monkeypatch)
|
|||
)
|
||||
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "claude-test-geo-breakdown-model"
|
||||
litellm.register_model(
|
||||
|
|
@ -3191,9 +3110,7 @@ GEMINI_DAY0_LAUNCH_PRICING = [
|
|||
|
||||
|
||||
@pytest.mark.parametrize("model,input_cost,output_cost,cache_read_cost", GEMINI_DAY0_LAUNCH_PRICING)
|
||||
def test_gemini_36_flash_and_35_flash_lite_launch_pricing(model, input_cost, output_cost, cache_read_cost):
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
def test_gemini_36_flash_and_35_flash_lite_launch_pricing(_local_model_cost_map, model, input_cost, output_cost, cache_read_cost):
|
||||
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
assert model_cost_map["input_cost_per_token"] == input_cost
|
||||
|
|
@ -3206,9 +3123,7 @@ def test_gemini_36_flash_and_35_flash_lite_launch_pricing(model, input_cost, out
|
|||
assert model_cost_map["max_input_tokens"] == 1048576
|
||||
|
||||
|
||||
def test_generic_cost_per_token_gemini_36_flash():
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
def test_generic_cost_per_token_gemini_36_flash(_local_model_cost_map):
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=1000,
|
||||
|
|
@ -3274,9 +3189,7 @@ def test_gemini_36_flash_batch_introductory_pricing(model, _local_model_cost_map
|
|||
assert model_cost_map["output_cost_per_token_batches"] == 1.875e-06
|
||||
|
||||
|
||||
def test_generic_cost_per_token_gemini_35_flash_lite():
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
def test_generic_cost_per_token_gemini_35_flash_lite(_local_model_cost_map):
|
||||
|
||||
usage = Usage(
|
||||
prompt_tokens=1000,
|
||||
|
|
@ -3300,8 +3213,8 @@ def test_generic_cost_per_token_gemini_35_flash_lite():
|
|||
@pytest.mark.parametrize(
|
||||
"service_tier,input_rate,cache_read_rate,cache_write_rate,output_rate",
|
||||
[
|
||||
("flex", 2.5e-6, 2.5e-7, 3.125e-6, 1.5e-5),
|
||||
("priority", 1e-5, 1e-6, 1.25e-5, 6e-5),
|
||||
("flex", 2e-6, 2e-7, 2.5e-6, 1e-5),
|
||||
("priority", 8e-6, 8e-7, 1e-5, 4e-5),
|
||||
],
|
||||
)
|
||||
def test_service_tier_cache_creation_rates_for_gpt_5_6(
|
||||
|
|
@ -3314,7 +3227,7 @@ def test_service_tier_cache_creation_rates_for_gpt_5_6(
|
|||
):
|
||||
"""Regression: gpt-5.6 publishes cache_creation_input_token_cost_flex/_priority, so a
|
||||
flex or priority request must bill cache writes at that tier's rate instead of falling
|
||||
back to the standard 6.25e-6 rate."""
|
||||
back to the standard cache-write rate."""
|
||||
usage = Usage(
|
||||
prompt_tokens=10_000,
|
||||
completion_tokens=500,
|
||||
|
|
@ -3361,8 +3274,8 @@ def test_fast_service_tier_bills_at_the_priority_rate(_local_model_cost_map):
|
|||
model="gpt-5.6-sol", usage=usage, custom_llm_provider="openai", service_tier="fast"
|
||||
)
|
||||
|
||||
expected_prompt = 800 * 1e-05 + 200 * 1e-06
|
||||
expected_completion = 500 * 6e-05
|
||||
expected_prompt = 800 * 8e-06 + 200 * 8e-07
|
||||
expected_completion = 500 * 4e-05
|
||||
|
||||
assert fast == priority
|
||||
assert fast[0] == pytest.approx(expected_prompt, rel=1e-9)
|
||||
|
|
@ -3397,8 +3310,8 @@ def test_fast_service_tier_matches_priority_above_the_context_threshold(_local_m
|
|||
)
|
||||
|
||||
assert fast == priority
|
||||
assert fast[0] == pytest.approx(300_000 * 1e-05, rel=1e-9)
|
||||
assert fast[1] == pytest.approx(1_000 * 4.5e-05, rel=1e-9)
|
||||
assert fast[0] == pytest.approx(300_000 * 8e-06, rel=1e-9)
|
||||
assert fast[1] == pytest.approx(1_000 * 3e-05, rel=1e-9)
|
||||
|
||||
|
||||
def test_priority_reasoning_tokens_bill_at_the_priority_output_rate(_local_model_cost_map):
|
||||
|
|
|
|||
|
|
@ -377,12 +377,12 @@ def test_get_cost_for_vertex_ai_gemini_web_search(model, custom_llm_provider):
|
|||
assert cost == 0.035, f"Expected $0.035 grounding cost, got ${cost}"
|
||||
|
||||
|
||||
def test_azure_assistant_features_integrated_cost_tracking():
|
||||
def test_azure_assistant_features_integrated_cost_tracking(monkeypatch):
|
||||
"""
|
||||
Test integrated cost tracking for Azure assistant features.
|
||||
"""
|
||||
# Force use of local model cost map for CI/CD consistency
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
model = "azure/gpt-4o"
|
||||
|
|
|
|||
|
|
@ -2844,7 +2844,7 @@ def test_anthropic_messages_pt_file_block_preserves_cache_control():
|
|||
assert text_block["cache_control"]["type"] == "ephemeral"
|
||||
|
||||
|
||||
def test_add_cache_point_tool_block_passes_ttl_for_claude_4_5():
|
||||
def test_add_cache_point_tool_block_passes_ttl_for_claude_4_5(monkeypatch):
|
||||
"""
|
||||
Tools with cache_control ttl should preserve the ttl in the cachePoint
|
||||
block for Claude 4.5+ models on Bedrock, matching the behavior of system
|
||||
|
|
@ -2867,7 +2867,7 @@ def test_add_cache_point_tool_block_passes_ttl_for_claude_4_5():
|
|||
|
||||
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
|
||||
old_cost = litellm.model_cost
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
try:
|
||||
tool_with_1h = {
|
||||
|
|
@ -2927,10 +2927,10 @@ def test_add_cache_point_tool_block_passes_ttl_for_claude_4_5():
|
|||
if old_env is None:
|
||||
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
|
||||
else:
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
|
||||
|
||||
|
||||
def test_bedrock_tools_pt_passes_ttl_for_claude_4_5():
|
||||
def test_bedrock_tools_pt_passes_ttl_for_claude_4_5(monkeypatch):
|
||||
"""
|
||||
End-to-end: _bedrock_tools_pt should produce cachePoint blocks with ttl
|
||||
for Claude 4.5+ models when tools have cache_control with ttl.
|
||||
|
|
@ -2944,7 +2944,7 @@ def test_bedrock_tools_pt_passes_ttl_for_claude_4_5():
|
|||
|
||||
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
|
||||
old_cost = litellm.model_cost
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
try:
|
||||
tools = [
|
||||
|
|
@ -2980,7 +2980,7 @@ def test_bedrock_tools_pt_passes_ttl_for_claude_4_5():
|
|||
if old_env is None:
|
||||
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
|
||||
else:
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
|
||||
|
||||
|
||||
def test_convert_to_anthropic_tool_result_openai_file_pdf_becomes_document():
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ def test_post_call_serializes_dict_with_datetime(logging_obj):
|
|||
assert "2026-05-11" in serialized
|
||||
|
||||
|
||||
def test_sentry_sample_rate():
|
||||
def test_sentry_sample_rate(monkeypatch):
|
||||
existing_sample_rate = os.getenv("SENTRY_API_SAMPLE_RATE")
|
||||
try:
|
||||
# test with default value by removing the environment variable
|
||||
|
|
@ -76,7 +76,7 @@ def test_sentry_sample_rate():
|
|||
assert os.environ.get("SENTRY_API_SAMPLE_RATE") == "1.0"
|
||||
|
||||
# test with custom value
|
||||
os.environ["SENTRY_API_SAMPLE_RATE"] = "0.5"
|
||||
monkeypatch.setenv("SENTRY_API_SAMPLE_RATE", "0.5")
|
||||
|
||||
set_callbacks(["sentry"])
|
||||
# Check if the custom sample rate is set correctly
|
||||
|
|
@ -86,13 +86,13 @@ def test_sentry_sample_rate():
|
|||
finally:
|
||||
# Restore the original environment variable
|
||||
if existing_sample_rate:
|
||||
os.environ["SENTRY_API_SAMPLE_RATE"] = existing_sample_rate
|
||||
monkeypatch.setenv("SENTRY_API_SAMPLE_RATE", existing_sample_rate)
|
||||
else:
|
||||
if "SENTRY_API_SAMPLE_RATE" in os.environ:
|
||||
del os.environ["SENTRY_API_SAMPLE_RATE"]
|
||||
|
||||
|
||||
def test_sentry_environment():
|
||||
def test_sentry_environment(monkeypatch):
|
||||
"""Test that SENTRY_ENVIRONMENT is properly handled during Sentry initialization"""
|
||||
existing_environment = os.getenv("SENTRY_ENVIRONMENT")
|
||||
existing_dsn = os.getenv("SENTRY_DSN")
|
||||
|
|
@ -115,7 +115,7 @@ def test_sentry_environment():
|
|||
|
||||
try:
|
||||
# Set a mock DSN to allow Sentry initialization
|
||||
os.environ["SENTRY_DSN"] = "https://test@sentry.io/123456"
|
||||
monkeypatch.setenv("SENTRY_DSN", "https://test@sentry.io/123456")
|
||||
|
||||
# Test with default value (no environment set)
|
||||
if existing_environment:
|
||||
|
|
@ -129,7 +129,7 @@ def test_sentry_environment():
|
|||
assert call_kwargs["environment"] == "production"
|
||||
|
||||
# Test with custom environment value
|
||||
os.environ["SENTRY_ENVIRONMENT"] = "development"
|
||||
monkeypatch.setenv("SENTRY_ENVIRONMENT", "development")
|
||||
|
||||
mock_init.reset_mock()
|
||||
set_callbacks(["sentry"])
|
||||
|
|
@ -139,7 +139,7 @@ def test_sentry_environment():
|
|||
assert call_kwargs["environment"] == "development"
|
||||
|
||||
# Test with staging environment
|
||||
os.environ["SENTRY_ENVIRONMENT"] = "staging"
|
||||
monkeypatch.setenv("SENTRY_ENVIRONMENT", "staging")
|
||||
|
||||
mock_init.reset_mock()
|
||||
set_callbacks(["sentry"])
|
||||
|
|
@ -154,13 +154,13 @@ def test_sentry_environment():
|
|||
finally:
|
||||
# Restore the original environment variables
|
||||
if existing_environment:
|
||||
os.environ["SENTRY_ENVIRONMENT"] = existing_environment
|
||||
monkeypatch.setenv("SENTRY_ENVIRONMENT", existing_environment)
|
||||
else:
|
||||
if "SENTRY_ENVIRONMENT" in os.environ:
|
||||
del os.environ["SENTRY_ENVIRONMENT"]
|
||||
|
||||
if existing_dsn:
|
||||
os.environ["SENTRY_DSN"] = existing_dsn
|
||||
monkeypatch.setenv("SENTRY_DSN", existing_dsn)
|
||||
else:
|
||||
if "SENTRY_DSN" in os.environ:
|
||||
del os.environ["SENTRY_DSN"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -8,7 +6,6 @@ from websockets.exceptions import ConnectionClosed
|
|||
|
||||
import litellm
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path
|
||||
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.litellm_core_utils.realtime_streaming import (
|
||||
|
|
@ -1326,7 +1323,7 @@ async def test_log_messages_includes_tools_in_model_call_details():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_guardrail_blocks_prompt_injection():
|
||||
async def test_realtime_guardrail_blocks_prompt_injection(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that when a transcription event containing prompt injection arrives from the
|
||||
backend, a registered guardrail blocks it — sending a warning to the client
|
||||
|
|
@ -1350,7 +1347,7 @@ async def test_realtime_guardrail_blocks_prompt_injection():
|
|||
event_hook=GuardrailEventHooks.realtime_input_transcription,
|
||||
default_on=True,
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
# --- client websocket mock ---
|
||||
client_ws = MagicMock()
|
||||
|
|
@ -1405,11 +1402,10 @@ async def test_realtime_guardrail_blocks_prompt_injection():
|
|||
f"Expected guardrail_violation error type, got: {error_events[0]}"
|
||||
)
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_guardrail_allows_clean_transcript():
|
||||
async def test_realtime_guardrail_allows_clean_transcript(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that a clean transcript passes through the guardrail and triggers
|
||||
response.create to the backend.
|
||||
|
|
@ -1430,7 +1426,7 @@ async def test_realtime_guardrail_allows_clean_transcript():
|
|||
event_hook=GuardrailEventHooks.realtime_input_transcription,
|
||||
default_on=True,
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
|
@ -1463,11 +1459,10 @@ async def test_realtime_guardrail_allows_clean_transcript():
|
|||
response_creates = [e for e in sent_to_backend if e.get("type") == "response.create"]
|
||||
assert len(response_creates) == 1, f"Clean transcript should trigger response.create, got: {sent_to_backend}"
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_text_input_guardrail_blocks_and_returns_error():
|
||||
async def test_realtime_text_input_guardrail_blocks_and_returns_error(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that when conversation.item.create arrives with text that triggers a guardrail,
|
||||
the proxy blocks it (doesn't forward to backend) and returns an error event directly
|
||||
|
|
@ -1495,7 +1490,7 @@ async def test_realtime_text_input_guardrail_blocks_and_returns_error():
|
|||
event_hook=GuardrailEventHooks.pre_call,
|
||||
default_on=True,
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
|
@ -1558,11 +1553,10 @@ async def test_realtime_text_input_guardrail_blocks_and_returns_error():
|
|||
]
|
||||
assert len(original_items) == 0, f"Blocked item should not be forwarded to backend, got: {original_items}"
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_function_call_output_guardrail_blocks_and_returns_error():
|
||||
async def test_realtime_function_call_output_guardrail_blocks_and_returns_error(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that a client-supplied function_call_output whose content triggers a
|
||||
guardrail is blocked: it is not forwarded to the backend, and an error
|
||||
|
|
@ -1590,7 +1584,7 @@ async def test_realtime_function_call_output_guardrail_blocks_and_returns_error(
|
|||
event_hook=GuardrailEventHooks.pre_call,
|
||||
default_on=True,
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
|
@ -1648,11 +1642,10 @@ async def test_realtime_function_call_output_guardrail_blocks_and_returns_error(
|
|||
assert sanitized_item["call_id"] == "call_123"
|
||||
assert "test@example.com" not in sanitized_item["output"]
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_function_call_output_guardrail_allows_clean_output():
|
||||
async def test_realtime_function_call_output_guardrail_allows_clean_output(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that a clean function_call_output passes through and reaches the backend
|
||||
when guardrails are configured.
|
||||
|
|
@ -1670,7 +1663,7 @@ async def test_realtime_function_call_output_guardrail_allows_clean_output():
|
|||
event_hook=GuardrailEventHooks.pre_call,
|
||||
default_on=True,
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
|
@ -1714,11 +1707,10 @@ async def test_realtime_function_call_output_guardrail_allows_clean_output():
|
|||
]
|
||||
assert len(forwarded) == 1, f"Clean function_call_output should be forwarded, got: {forwarded}"
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_text_input_guardrail_uses_pre_call_mode():
|
||||
async def test_realtime_text_input_guardrail_uses_pre_call_mode(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that _has_realtime_guardrails returns True for a guardrail configured with
|
||||
pre_call mode (not just realtime_input_transcription).
|
||||
|
|
@ -1736,7 +1728,7 @@ async def test_realtime_text_input_guardrail_uses_pre_call_mode():
|
|||
event_hook=GuardrailEventHooks.pre_call,
|
||||
default_on=True,
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
client_ws = MagicMock()
|
||||
backend_ws = MagicMock()
|
||||
|
|
@ -1751,11 +1743,10 @@ async def test_realtime_text_input_guardrail_uses_pre_call_mode():
|
|||
"pre_call-only guardrail must not disable server_vad auto-response"
|
||||
)
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_session_created_injects_session_update_for_audio_guardrail():
|
||||
async def test_realtime_session_created_injects_session_update_for_audio_guardrail(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that when an audio transcription guardrail is configured, a session.created
|
||||
event from the backend triggers a session.update injection (create_response: false)
|
||||
|
|
@ -1775,7 +1766,7 @@ async def test_realtime_session_created_injects_session_update_for_audio_guardra
|
|||
event_hook=GuardrailEventHooks.realtime_input_transcription,
|
||||
default_on=True,
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
|
@ -1809,11 +1800,12 @@ async def test_realtime_session_created_injects_session_update_for_audio_guardra
|
|||
"GA session.update must nest turn_detection under audio.input"
|
||||
)
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_session_created_does_not_inject_session_update_for_pre_call_only():
|
||||
async def test_realtime_session_created_does_not_inject_session_update_for_pre_call_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""
|
||||
pre_call-only guardrails must not inject create_response:false on realtime
|
||||
sessions — that breaks server_vad for audio-only voice agents (e.g. Model Armor).
|
||||
|
|
@ -1831,7 +1823,7 @@ async def test_realtime_session_created_does_not_inject_session_update_for_pre_c
|
|||
event_hook=GuardrailEventHooks.pre_call,
|
||||
default_on=True,
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
|
@ -1853,11 +1845,10 @@ async def test_realtime_session_created_does_not_inject_session_update_for_pre_c
|
|||
session_updates = [e for e in sent_to_backend if e.get("type") == "session.update"]
|
||||
assert len(session_updates) == 0, f"pre_call-only guardrail must not inject session.update, got: {sent_to_backend}"
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_and_post_call_guardrails_do_not_disable_server_vad():
|
||||
async def test_pre_call_and_post_call_guardrails_do_not_disable_server_vad(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Model Armor-style pre_call + post_call must not gate audio VAD."""
|
||||
import litellm
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
|
|
@ -1867,18 +1858,22 @@ async def test_pre_call_and_post_call_guardrails_do_not_disable_server_vad():
|
|||
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
|
||||
return inputs
|
||||
|
||||
litellm.callbacks = [
|
||||
ModelArmorStyleGuardrail(
|
||||
guardrail_name="model_armor_all_pre_call",
|
||||
event_hook=GuardrailEventHooks.pre_call,
|
||||
default_on=False,
|
||||
),
|
||||
ModelArmorStyleGuardrail(
|
||||
guardrail_name="model_armor_all_post_call",
|
||||
event_hook=GuardrailEventHooks.post_call,
|
||||
default_on=False,
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"callbacks",
|
||||
[
|
||||
ModelArmorStyleGuardrail(
|
||||
guardrail_name="model_armor_all_pre_call",
|
||||
event_hook=GuardrailEventHooks.pre_call,
|
||||
default_on=False,
|
||||
),
|
||||
ModelArmorStyleGuardrail(
|
||||
guardrail_name="model_armor_all_post_call",
|
||||
event_hook=GuardrailEventHooks.post_call,
|
||||
default_on=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
client_ws = MagicMock()
|
||||
backend_ws = MagicMock()
|
||||
|
|
@ -1900,11 +1895,10 @@ async def test_pre_call_and_post_call_guardrails_do_not_disable_server_vad():
|
|||
assert streaming._has_realtime_guardrails() is True
|
||||
assert streaming._has_audio_transcription_guardrails() is False
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_session_after_n_fails_closes_connection():
|
||||
async def test_end_session_after_n_fails_closes_connection(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that end_session_after_n_fails=2 closes the backend websocket after
|
||||
the second guardrail violation in a session.
|
||||
|
|
@ -1923,7 +1917,7 @@ async def test_end_session_after_n_fails_closes_connection():
|
|||
default_on=True,
|
||||
end_session_after_n_fails=2,
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
|
@ -1948,11 +1942,10 @@ async def test_end_session_after_n_fails_closes_connection():
|
|||
assert backend_ws.close.called, "Expected backend_ws.close() to be called after 2 violations"
|
||||
assert streaming._violation_count == 2
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_violation_end_session_closes_on_first_fail():
|
||||
async def test_on_violation_end_session_closes_on_first_fail(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that on_violation='end_session' closes the session immediately on the
|
||||
first violation, regardless of end_session_after_n_fails.
|
||||
|
|
@ -1971,7 +1964,7 @@ async def test_on_violation_end_session_closes_on_first_fail():
|
|||
default_on=True,
|
||||
on_violation="end_session",
|
||||
)
|
||||
litellm.callbacks = [guardrail]
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
client_ws = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
|
|
@ -1995,7 +1988,6 @@ async def test_on_violation_end_session_closes_on_first_fail():
|
|||
assert backend_ws.close.called, "Expected session to close immediately with on_violation=end_session"
|
||||
assert streaming._violation_count == 1
|
||||
|
||||
litellm.callbacks = [] # cleanup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -2898,53 +2890,47 @@ def _transcription_guardrail():
|
|||
)
|
||||
|
||||
|
||||
def test_setup_folds_in_auto_response_disable_when_transcription_guardrail_active():
|
||||
def test_setup_folds_in_auto_response_disable_when_transcription_guardrail_active(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Gemini rejects a second setup, so a transcription guardrail's auto-response
|
||||
disable must be folded into the one-and-only setup; otherwise the model
|
||||
auto-responds and the guardrail is bypassed."""
|
||||
import litellm
|
||||
|
||||
litellm.callbacks = [_transcription_guardrail()]
|
||||
try:
|
||||
streaming = RealTimeStreaming(MagicMock(), MagicMock(), MagicMock())
|
||||
setup = json.dumps(
|
||||
{
|
||||
"setup": {
|
||||
"model": "models/gemini-3.1-flash-live-preview",
|
||||
"generationConfig": {"responseModalities": ["AUDIO"]},
|
||||
"inputAudioTranscription": {},
|
||||
}
|
||||
monkeypatch.setattr(litellm, "callbacks", [_transcription_guardrail()])
|
||||
streaming = RealTimeStreaming(MagicMock(), MagicMock(), MagicMock())
|
||||
setup = json.dumps(
|
||||
{
|
||||
"setup": {
|
||||
"model": "models/gemini-3.1-flash-live-preview",
|
||||
"generationConfig": {"responseModalities": ["AUDIO"]},
|
||||
"inputAudioTranscription": {},
|
||||
}
|
||||
)
|
||||
out = json.loads(streaming._maybe_inject_guardrail_auto_response_disable(setup))
|
||||
aad = out["setup"]["realtimeInputConfig"]["automaticActivityDetection"]
|
||||
assert aad["disabled"] is True
|
||||
finally:
|
||||
litellm.callbacks = []
|
||||
}
|
||||
)
|
||||
out = json.loads(streaming._maybe_inject_guardrail_auto_response_disable(setup))
|
||||
aad = out["setup"]["realtimeInputConfig"]["automaticActivityDetection"]
|
||||
assert aad["disabled"] is True
|
||||
|
||||
|
||||
def test_setup_unchanged_without_transcription_guardrail():
|
||||
def test_setup_unchanged_without_transcription_guardrail(monkeypatch: pytest.MonkeyPatch):
|
||||
import litellm
|
||||
|
||||
litellm.callbacks = []
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
streaming = RealTimeStreaming(MagicMock(), MagicMock(), MagicMock())
|
||||
setup = json.dumps({"setup": {"model": "x", "generationConfig": {"responseModalities": ["AUDIO"]}}})
|
||||
out = streaming._maybe_inject_guardrail_auto_response_disable(setup)
|
||||
assert json.loads(out) == json.loads(setup)
|
||||
|
||||
|
||||
def test_non_bidi_setup_left_untouched_for_followup_capable_providers():
|
||||
def test_non_bidi_setup_left_untouched_for_followup_capable_providers(monkeypatch: pytest.MonkeyPatch):
|
||||
"""OpenAI realtime accepts a follow-up session.update, so a non-bidi message
|
||||
(no top-level 'setup' key) must be left untouched even with a guardrail on."""
|
||||
import litellm
|
||||
|
||||
litellm.callbacks = [_transcription_guardrail()]
|
||||
try:
|
||||
streaming = RealTimeStreaming(MagicMock(), MagicMock(), MagicMock())
|
||||
msg = json.dumps({"type": "session.update", "session": {"instructions": "hi"}})
|
||||
assert streaming._maybe_inject_guardrail_auto_response_disable(msg) == msg
|
||||
finally:
|
||||
litellm.callbacks = []
|
||||
monkeypatch.setattr(litellm, "callbacks", [_transcription_guardrail()])
|
||||
streaming = RealTimeStreaming(MagicMock(), MagicMock(), MagicMock())
|
||||
msg = json.dumps({"type": "session.update", "session": {"instructions": "hi"}})
|
||||
assert streaming._maybe_inject_guardrail_auto_response_disable(msg) == msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -100,12 +100,12 @@ class TestEncodeUrlPathSegment:
|
|||
|
||||
@pytest.mark.parametrize("value", ["", ".", "..", None])
|
||||
def test_rejects_empty_and_dot_segments(self, value):
|
||||
with pytest.raises(ValueError, match="resource_id (is required|cannot be a dot path segment)"):
|
||||
with pytest.raises(ValueError, match=r"resource_id (is required|cannot be a dot path segment)"):
|
||||
encode_url_path_segment(value, field_name="resource_id")
|
||||
|
||||
@pytest.mark.parametrize("value", ["../model", "model/../other", "/model"])
|
||||
def test_rejects_dot_segments_in_multi_segment_paths(self, value):
|
||||
with pytest.raises(ValueError, match="model (is required|cannot be a dot path segment)"):
|
||||
with pytest.raises(ValueError, match=r"model (is required|cannot be a dot path segment)"):
|
||||
encode_url_path_segments(value, field_name="model")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -66,5 +66,5 @@ def test_prepare_completion_kwargs_keeps_prompt_cache_key_through_responses_rero
|
|||
{"custom_llm_provider": "openai"},
|
||||
thinking={"type": "enabled", "budget_tokens": 1024},
|
||||
)
|
||||
assert completion_kwargs["model"] == "responses/openai/gpt-5.6-luna"
|
||||
assert completion_kwargs["model"] == "openai/responses/gpt-5.6-luna"
|
||||
assert completion_kwargs["prompt_cache_key"] == "session-abc"
|
||||
|
|
|
|||
|
|
@ -217,7 +217,10 @@ async def _async_return(value):
|
|||
|
||||
def test_anthropic_experimental_pass_through_messages_handler_custom_llm_provider():
|
||||
"""
|
||||
Test that litellm.completion is called when a custom LLM provider is given
|
||||
Test that litellm.completion is called when a custom LLM provider is given.
|
||||
|
||||
Provider resolution now happens exactly once, inside litellm.completion itself
|
||||
(BerriAI/litellm#37716), so the handler passes the original unresolved model through.
|
||||
"""
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
anthropic_messages_handler,
|
||||
|
|
@ -241,7 +244,7 @@ def test_anthropic_experimental_pass_through_messages_handler_custom_llm_provide
|
|||
# Verify that the custom provider was passed through
|
||||
call_kwargs = mock_completion.call_args.kwargs
|
||||
assert call_kwargs["custom_llm_provider"] == "my-custom-llm"
|
||||
assert call_kwargs["model"] == "my-custom-llm/my-custom-model"
|
||||
assert call_kwargs["model"] == "my-custom-model"
|
||||
assert call_kwargs["api_key"] == "test-api-key"
|
||||
|
||||
|
||||
|
|
@ -525,7 +528,7 @@ class TestThinkingSummaryPreservation:
|
|||
finally:
|
||||
litellm.reasoning_auto_summary = original
|
||||
|
||||
def test_summary_added_when_env_var_set(self):
|
||||
def test_summary_added_when_env_var_set(self, monkeypatch):
|
||||
"""When LITELLM_REASONING_AUTO_SUMMARY env var is true, summary is added."""
|
||||
import litellm
|
||||
from litellm.llms.anthropic.experimental_pass_through.adapters.handler import (
|
||||
|
|
@ -535,7 +538,7 @@ class TestThinkingSummaryPreservation:
|
|||
original = litellm.reasoning_auto_summary
|
||||
try:
|
||||
litellm.reasoning_auto_summary = False
|
||||
os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = "true"
|
||||
monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true")
|
||||
completion_kwargs = {
|
||||
"model": "responses/gpt-5.2",
|
||||
"custom_llm_provider": "openai",
|
||||
|
|
@ -997,3 +1000,108 @@ def test_first_party_claude_4_8_plus_cost_map_entries_carry_mid_conversation_sys
|
|||
and info.get("supports_mid_conversation_system") is not True
|
||||
]
|
||||
assert missing == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"requested_model, expected_wire_model, expected_url",
|
||||
[
|
||||
(
|
||||
"perplexity/perplexity/kimi-k3",
|
||||
"perplexity/kimi-k3",
|
||||
"https://api.perplexity.ai/v1/responses",
|
||||
),
|
||||
(
|
||||
"perplexity/perplexity/sonar",
|
||||
"perplexity/sonar",
|
||||
"https://api.perplexity.ai/v1/responses",
|
||||
),
|
||||
("perplexity/sonar", "sonar", "https://api.perplexity.ai/chat/completions"),
|
||||
],
|
||||
)
|
||||
async def test_messages_strips_provider_prefix_exactly_once(
|
||||
requested_model, expected_wire_model, expected_url
|
||||
):
|
||||
"""
|
||||
BerriAI/litellm#37716: only the leading provider segment may be stripped on the way upstream.
|
||||
|
||||
A multi-segment id such as perplexity/perplexity/kimi-k3 must reach the provider as
|
||||
perplexity/kimi-k3, matching what /v1/chat/completions and /v1/responses already send.
|
||||
|
||||
The endpoint is asserted alongside the body because perplexity/perplexity/sonar is a
|
||||
Responses-only deployment whose bare id perplexity/sonar is an ordinary chat model, so
|
||||
stripping the prefix must not also move the request onto chat/completions.
|
||||
|
||||
The subject is the outbound request, so the transport is cut at the wire rather than
|
||||
stubbed with a response body: these ids take different bridges (chat completions
|
||||
versus the Responses API) and would otherwise need different response shapes.
|
||||
"""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(self, request, **kwargs):
|
||||
captured["body"] = json.loads(request.content)
|
||||
captured["url"] = str(request.url)
|
||||
raise httpx.ConnectError("cut at the wire", request=request)
|
||||
|
||||
with (
|
||||
patch.object(httpx.AsyncClient, "send", fake_send),
|
||||
pytest.raises(litellm.exceptions.InternalServerError),
|
||||
):
|
||||
await litellm.anthropic.messages.acreate(
|
||||
max_tokens=100,
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
model=requested_model,
|
||||
api_key="test-api-key",
|
||||
)
|
||||
|
||||
assert captured["body"]["model"] == expected_wire_model
|
||||
assert captured["url"] == expected_url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"requested_model, expected_reported_model",
|
||||
[
|
||||
("perplexity/perplexity/kimi-k3", "perplexity/kimi-k3"),
|
||||
("perplexity/sonar", "sonar"),
|
||||
],
|
||||
)
|
||||
async def test_messages_streaming_reports_provider_local_model(requested_model, expected_reported_model):
|
||||
"""
|
||||
BerriAI/litellm#37716: the wire keeps every segment, so ``message_start`` must still
|
||||
report the id the provider itself knows rather than the caller's prefixed deployment id.
|
||||
"""
|
||||
|
||||
class _EmptyStream:
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
raise StopAsyncIteration
|
||||
|
||||
with patch("litellm.acompletion", new=AsyncMock(return_value=_EmptyStream())):
|
||||
stream = await litellm.anthropic.messages.acreate(
|
||||
max_tokens=100,
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
model=requested_model,
|
||||
api_key="test-api-key",
|
||||
stream=True,
|
||||
)
|
||||
first_event = await stream.__anext__()
|
||||
|
||||
assert json.loads(first_event.decode().split("data: ", 1)[1])["message"]["model"] == expected_reported_model
|
||||
|
||||
|
||||
def test_messages_sync_streaming_reports_provider_local_model():
|
||||
"""Same guarantee as the async bridge, at the sync call site."""
|
||||
with patch("litellm.completion", new=MagicMock(return_value=iter(()))):
|
||||
stream = litellm.anthropic.messages.create(
|
||||
max_tokens=100,
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
model="perplexity/perplexity/kimi-k3",
|
||||
api_key="test-api-key",
|
||||
stream=True,
|
||||
)
|
||||
first_event = next(iter(stream))
|
||||
|
||||
assert json.loads(first_event.decode().split("data: ", 1)[1])["message"]["model"] == "perplexity/kimi-k3"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ Regression tests for the /v1/messages request-parse fast paths:
|
|||
while resolving the (static) type hints only once per process.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.utils import (
|
||||
AnthropicMessagesRequestUtils,
|
||||
|
|
@ -88,3 +90,61 @@ def test_drop_params_keeps_speed_for_supporting_model():
|
|||
litellm.drop_params = original
|
||||
|
||||
assert result == {"speed": "fast"}
|
||||
|
||||
|
||||
def test_drop_params_strips_sampling_params_for_unsupported_model(monkeypatch):
|
||||
# claude-opus-4-7 has supports_sampling_params: false in the model map; the
|
||||
# API 400s on these rather than ignoring them.
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
result = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
|
||||
params={"temperature": 0.3, "top_p": 0.9, "top_k": 40, "stream": True},
|
||||
model="claude-opus-4-7",
|
||||
drop_params=True,
|
||||
)
|
||||
|
||||
assert result == {"stream": True}
|
||||
|
||||
|
||||
def test_drop_params_strips_sampling_params_for_provider_prefixed_model(monkeypatch):
|
||||
# Vertex-routed ids must resolve the same capability flag.
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
result = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
|
||||
params={"temperature": 0.3, "top_p": 0.9, "top_k": 40},
|
||||
model="vertex_ai/claude-opus-4-7",
|
||||
drop_params=True,
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_sampling_params_kept_for_supporting_model(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
result = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
|
||||
params={"temperature": 0.3, "top_p": 0.9, "top_k": 40},
|
||||
model="claude-sonnet-4-6",
|
||||
drop_params=True,
|
||||
)
|
||||
|
||||
assert result == {"temperature": 0.3, "top_p": 0.9, "top_k": 40}
|
||||
|
||||
|
||||
def test_temperature_1_kept_for_unsupported_model(monkeypatch):
|
||||
# temperature=1 is the one value these models still accept.
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
result = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
|
||||
params={"temperature": 1},
|
||||
model="claude-opus-4-7",
|
||||
drop_params=True,
|
||||
)
|
||||
|
||||
assert result == {"temperature": 1}
|
||||
|
||||
|
||||
def test_sampling_param_raises_clean_400_without_drop_params(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(litellm.utils.UnsupportedParamsError, match="does not support temperature"):
|
||||
AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
|
||||
params={"temperature": 0.3},
|
||||
model="claude-opus-4-7",
|
||||
drop_params=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../..")))
|
||||
|
||||
import litellm
|
||||
from litellm.llms.anthropic.experimental_pass_through.responses_adapters.handler import (
|
||||
LiteLLMMessagesToResponsesAPIHandler,
|
||||
_build_responses_kwargs,
|
||||
)
|
||||
|
||||
|
|
@ -43,3 +49,36 @@ def test_build_responses_kwargs_without_metadata_sets_no_prompt_cache_key():
|
|||
)
|
||||
assert "user" not in responses_kwargs
|
||||
assert "prompt_cache_key" not in responses_kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"requested_model, expected_reported_model",
|
||||
[
|
||||
("openai/gpt-5.6-luna", "gpt-5.6-luna"),
|
||||
("perplexity/perplexity/kimi-k3", "perplexity/kimi-k3"),
|
||||
],
|
||||
)
|
||||
async def test_streaming_message_start_reports_the_provider_local_model(requested_model, expected_reported_model):
|
||||
"""
|
||||
BerriAI/litellm#37716 sends the caller's unresolved id down this bridge so the provider
|
||||
resolves it once. ``message_start`` is a reporting field rather than a wire value, so it
|
||||
keeps naming the model as the provider knows it, with only the leading provider segment gone.
|
||||
"""
|
||||
|
||||
async def empty_stream():
|
||||
return
|
||||
yield
|
||||
|
||||
with patch.object(litellm, "aresponses", AsyncMock(return_value=empty_stream())):
|
||||
sse = await LiteLLMMessagesToResponsesAPIHandler.async_anthropic_messages_handler(
|
||||
max_tokens=1024,
|
||||
messages=MESSAGES,
|
||||
model=requested_model,
|
||||
stream=True,
|
||||
custom_llm_provider=requested_model.split("/")[0],
|
||||
)
|
||||
events = [json.loads(chunk.decode().split("data: ", 1)[1]) async for chunk in sse]
|
||||
|
||||
message_start = next(e for e in events if e["type"] == "message_start")
|
||||
assert message_start["message"]["model"] == expected_reported_model
|
||||
|
|
|
|||
|
|
@ -845,14 +845,14 @@ class TestTranslateThinkingToReasoning:
|
|||
finally:
|
||||
litellm.reasoning_auto_summary = original
|
||||
|
||||
def test_summary_added_when_env_var_set(self):
|
||||
def test_summary_added_when_env_var_set(self, monkeypatch):
|
||||
"""When LITELLM_REASONING_AUTO_SUMMARY env var is true, summary is included."""
|
||||
import litellm
|
||||
|
||||
original = litellm.reasoning_auto_summary
|
||||
try:
|
||||
litellm.reasoning_auto_summary = False
|
||||
os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = "true"
|
||||
monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true")
|
||||
result = _ADAPTER.translate_thinking_to_reasoning(
|
||||
{
|
||||
"type": "enabled",
|
||||
|
|
|
|||
|
|
@ -929,7 +929,7 @@ class TestValidateEnvironmentAuthToken:
|
|||
config = AnthropicModelInfo()
|
||||
with mock_patch.dict("os.environ", {}, clear=True):
|
||||
with pytest.raises(
|
||||
Exception, match="ANTHROPIC_API_KEY.*ANTHROPIC_AUTH_TOKEN"
|
||||
Exception, match=r"ANTHROPIC_API_KEY.*ANTHROPIC_AUTH_TOKEN"
|
||||
):
|
||||
config.validate_environment(
|
||||
headers={},
|
||||
|
|
|
|||
|
|
@ -239,8 +239,8 @@ class TestAPISerpentSearchIntegration:
|
|||
return mock_response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asearch_quick_default(self):
|
||||
os.environ["APISERPENT_API_KEY"] = "test-api-key"
|
||||
async def test_asearch_quick_default(self, monkeypatch):
|
||||
monkeypatch.setenv("APISERPENT_API_KEY", "test-api-key")
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
||||
new_callable=AsyncMock,
|
||||
|
|
@ -269,8 +269,8 @@ class TestAPISerpentSearchIntegration:
|
|||
assert response.results[0].title == "Test Result"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asearch_deep(self):
|
||||
os.environ["APISERPENT_API_KEY"] = "test-api-key"
|
||||
async def test_asearch_deep(self, monkeypatch):
|
||||
monkeypatch.setenv("APISERPENT_API_KEY", "test-api-key")
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
||||
new_callable=AsyncMock,
|
||||
|
|
|
|||
|
|
@ -812,7 +812,6 @@ async def test_azure_client_reuse(function_name, is_async, args):
|
|||
"""
|
||||
Test that multiple Azure API calls reuse the same Azure OpenAI client
|
||||
"""
|
||||
litellm.set_verbose = True
|
||||
|
||||
# Determine which client class to mock based on whether the test is async
|
||||
client_path = (
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ class TestAzureMAIImageGeneration:
|
|||
assert not AzureFoundryMAIImageGenerationConfig.is_mai_model("flux.2-pro")
|
||||
assert not AzureFoundryMAIImageGenerationConfig.is_mai_model("MAI-DS-R1")
|
||||
|
||||
def test_mai_flash_and_2e_model_pricing_in_cost_map(self):
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
def test_mai_flash_and_2e_model_pricing_in_cost_map(self, monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
flash_info = litellm.get_model_info(
|
||||
|
|
@ -328,8 +328,8 @@ class TestAzureMAIImageGeneration:
|
|||
assert image_response.usage.total_tokens == 1046
|
||||
assert image_response.size == "1792x1024"
|
||||
|
||||
def test_mai_image_cost_calculator_token_based(self):
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
def test_mai_image_cost_calculator_token_based(self, monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
model = "azure_ai/MAI-Image-2.5"
|
||||
model_info = litellm.get_model_info(model=model, custom_llm_provider="azure_ai")
|
||||
|
|
@ -360,8 +360,8 @@ class TestAzureMAIImageGeneration:
|
|||
)
|
||||
assert round(cost, 10) == round(expected_cost, 10)
|
||||
|
||||
def test_mai_image_cost_calculator_falls_back_to_flat_image_pricing(self):
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
def test_mai_image_cost_calculator_falls_back_to_flat_image_pricing(self, monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
model = "azure_ai/MAI-Image-2.5"
|
||||
model_info = litellm.get_model_info(model=model, custom_llm_provider="azure_ai")
|
||||
|
|
|
|||
|
|
@ -678,10 +678,10 @@ def test_transform_request_helper_includes_anthropic_beta_and_tools():
|
|||
assert fields["tools"][0]["type"] == "computer_20250124"
|
||||
|
||||
|
||||
def test_parallel_tool_calls_config_kept_for_sonnet_5():
|
||||
def test_parallel_tool_calls_config_kept_for_sonnet_5(monkeypatch):
|
||||
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
|
||||
old_cost = litellm.model_cost
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
try:
|
||||
config = AmazonConverseConfig()
|
||||
|
|
@ -708,7 +708,7 @@ def test_parallel_tool_calls_config_kept_for_sonnet_5():
|
|||
if old_env is None:
|
||||
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
|
||||
else:
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
|
||||
|
||||
|
||||
def test_parallel_tool_calls_config_dropped_for_ttl_only_model(
|
||||
|
|
@ -3025,7 +3025,7 @@ def test_request_metadata_key_constraints():
|
|||
long_key = "a" * 257
|
||||
invalid_metadata = {long_key: "value"}
|
||||
|
||||
with pytest.raises(Exception, match="(?i)key length|256 characters"):
|
||||
with pytest.raises(Exception, match=r"(?i)key length|256 characters"):
|
||||
config.transform_request(
|
||||
model="anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
messages=messages,
|
||||
|
|
@ -3037,7 +3037,7 @@ def test_request_metadata_key_constraints():
|
|||
# Test empty key
|
||||
invalid_metadata = {"": "value"}
|
||||
|
||||
with pytest.raises(Exception, match="(?i)key length|empty"):
|
||||
with pytest.raises(Exception, match=r"(?i)key length|empty"):
|
||||
config.transform_request(
|
||||
model="anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
messages=messages,
|
||||
|
|
@ -3057,7 +3057,7 @@ def test_request_metadata_value_constraints():
|
|||
long_value = "a" * 257
|
||||
invalid_metadata = {"key": long_value}
|
||||
|
||||
with pytest.raises(Exception, match="(?i)value length|256 characters"):
|
||||
with pytest.raises(Exception, match=r"(?i)value length|256 characters"):
|
||||
config.transform_request(
|
||||
model="anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
messages=messages,
|
||||
|
|
@ -3575,7 +3575,7 @@ def test_drop_thinking_param_when_thinking_blocks_missing():
|
|||
litellm.modify_params = original_modify_params
|
||||
|
||||
|
||||
def test_supports_native_structured_outputs():
|
||||
def test_supports_native_structured_outputs(monkeypatch):
|
||||
"""Test model detection for native structured outputs support.
|
||||
|
||||
Support is driven by the ``supports_native_structured_output`` flag in the
|
||||
|
|
@ -3583,7 +3583,7 @@ def test_supports_native_structured_outputs():
|
|||
"""
|
||||
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
|
||||
old_cost = litellm.model_cost
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
try:
|
||||
config = AmazonConverseConfig()
|
||||
|
|
@ -3645,7 +3645,7 @@ def test_supports_native_structured_outputs():
|
|||
if old_env is None:
|
||||
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
|
||||
else:
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
|
||||
|
||||
|
||||
def test_create_output_config_for_response_format():
|
||||
|
|
@ -3683,11 +3683,11 @@ def test_create_output_config_for_response_format():
|
|||
assert parsed_schema == expected
|
||||
|
||||
|
||||
def test_translate_response_format_native_output_config():
|
||||
def test_translate_response_format_native_output_config(monkeypatch):
|
||||
"""For supported models, _translate_response_format_param should produce outputConfig."""
|
||||
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
|
||||
old_cost = litellm.model_cost
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
try:
|
||||
config = AmazonConverseConfig()
|
||||
|
|
@ -3743,7 +3743,7 @@ def test_translate_response_format_native_output_config():
|
|||
if old_env is None:
|
||||
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
|
||||
else:
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
|
||||
|
||||
|
||||
def test_translate_response_format_fallback_tool_call():
|
||||
|
|
@ -3778,11 +3778,11 @@ def test_translate_response_format_fallback_tool_call():
|
|||
assert result["json_mode"] is True
|
||||
|
||||
|
||||
def test_native_structured_output_no_fake_stream():
|
||||
def test_native_structured_output_no_fake_stream(monkeypatch):
|
||||
"""When using native structured outputs with streaming, fake_stream should NOT be set."""
|
||||
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
|
||||
old_cost = litellm.model_cost
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
try:
|
||||
config = AmazonConverseConfig()
|
||||
|
|
@ -3828,7 +3828,7 @@ def test_native_structured_output_no_fake_stream():
|
|||
if old_env is None:
|
||||
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
|
||||
else:
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
|
||||
|
||||
|
||||
def test_transform_request_with_output_config():
|
||||
|
|
@ -4116,7 +4116,7 @@ def test_add_additional_properties_definitions():
|
|||
)
|
||||
|
||||
|
||||
def test_json_object_no_schema_skips_tool_injection():
|
||||
def test_json_object_no_schema_skips_tool_injection(monkeypatch):
|
||||
"""response_format: {type: json_object} with no schema should NOT inject
|
||||
the synthetic json_tool_call tool.
|
||||
|
||||
|
|
@ -4126,7 +4126,7 @@ def test_json_object_no_schema_skips_tool_injection():
|
|||
the model respond naturally with the JSON the caller asked for."""
|
||||
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
|
||||
old_cost = litellm.model_cost
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
try:
|
||||
config = AmazonConverseConfig()
|
||||
|
|
@ -4152,7 +4152,7 @@ def test_json_object_no_schema_skips_tool_injection():
|
|||
if old_env is None:
|
||||
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
|
||||
else:
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
|
||||
|
||||
|
||||
def test_output_config_applies_additional_properties():
|
||||
|
|
@ -4805,7 +4805,7 @@ def test_cache_control_injection_tool_config_not_added_without_injection_point()
|
|||
assert all("cachePoint" not in tool for tool in tools)
|
||||
|
||||
|
||||
def test_cache_control_injection_tool_config_honors_ttl_for_supported_model():
|
||||
def test_cache_control_injection_tool_config_honors_ttl_for_supported_model(monkeypatch):
|
||||
"""
|
||||
Regression test: cache_control_injection_points with location=tool_config
|
||||
must honor the requested `control.ttl`, mirroring the message/system
|
||||
|
|
@ -4819,7 +4819,7 @@ def test_cache_control_injection_tool_config_honors_ttl_for_supported_model():
|
|||
"""
|
||||
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
|
||||
old_cost = litellm.model_cost
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
try:
|
||||
config = AmazonConverseConfig()
|
||||
|
|
@ -4858,10 +4858,10 @@ def test_cache_control_injection_tool_config_honors_ttl_for_supported_model():
|
|||
if old_env is None:
|
||||
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
|
||||
else:
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
|
||||
|
||||
|
||||
def test_cache_control_injection_tool_config_honors_ttl_for_regional_model_lacking_own_pricing():
|
||||
def test_cache_control_injection_tool_config_honors_ttl_for_regional_model_lacking_own_pricing(monkeypatch):
|
||||
"""
|
||||
Regression test: a regional pricing entry that omits
|
||||
`cache_creation_input_token_cost_above_1hr` (e.g. `jp.anthropic.claude-opus-4-7`)
|
||||
|
|
@ -4870,7 +4870,7 @@ def test_cache_control_injection_tool_config_honors_ttl_for_regional_model_lacki
|
|||
"""
|
||||
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
|
||||
old_cost = litellm.model_cost
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
try:
|
||||
assert "cache_creation_input_token_cost_above_1hr" not in litellm.model_cost["jp.anthropic.claude-opus-4-7"]
|
||||
|
|
@ -4911,7 +4911,7 @@ def test_cache_control_injection_tool_config_honors_ttl_for_regional_model_lacki
|
|||
if old_env is None:
|
||||
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
|
||||
else:
|
||||
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
|
||||
|
||||
|
||||
def test_cache_control_injection_tool_config_drops_ttl_for_unsupported_model():
|
||||
|
|
|
|||
|
|
@ -153,7 +153,6 @@ class TestBedrockAsyncInvokeEmbedding:
|
|||
|
||||
def test_async_invoke_twelvelabs_embedding_with_mock(self):
|
||||
"""Test async invoke embedding with mocked HTTP calls."""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
model = "bedrock/async_invoke/twelvelabs.marengo-embed-2-7-v1:0"
|
||||
|
|
@ -193,7 +192,6 @@ class TestBedrockAsyncInvokeEmbedding:
|
|||
@pytest.mark.asyncio
|
||||
async def test_async_invoke_twelvelabs_embedding_async_with_mock(self):
|
||||
"""Test async invoke embedding with async calls."""
|
||||
litellm.set_verbose = True
|
||||
client = AsyncHTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
model = "bedrock/async_invoke/twelvelabs.marengo-embed-2-7-v1:0"
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ test_image_base64 = "data:image/png,test_image_base64_data"
|
|||
)
|
||||
def test_bedrock_embedding_with_api_key_bearer_token(model, input_type, embed_response):
|
||||
"""Test embedding functionality with bearer token authentication"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
|
||||
|
|
@ -98,7 +97,6 @@ def test_bedrock_embedding_with_env_variable_bearer_token(
|
|||
model, input_type, embed_response
|
||||
):
|
||||
"""Test embedding functionality with bearer token from environment variable"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "env-bearer-token-12345"
|
||||
|
||||
|
|
@ -130,7 +128,6 @@ def test_bedrock_embedding_with_env_variable_bearer_token(
|
|||
@pytest.mark.asyncio
|
||||
async def test_async_bedrock_embedding_with_bearer_token():
|
||||
"""Test async embedding functionality with bearer token authentication"""
|
||||
litellm.set_verbose = True
|
||||
client = AsyncHTTPHandler()
|
||||
test_api_key = "async-bearer-token-12345"
|
||||
model = "bedrock/amazon.titan-embed-text-v1"
|
||||
|
|
@ -160,7 +157,6 @@ async def test_async_bedrock_embedding_with_bearer_token():
|
|||
|
||||
def test_bedrock_embedding_with_sigv4():
|
||||
"""Test embedding falls back to SigV4 auth when no bearer token is provided"""
|
||||
litellm.set_verbose = True
|
||||
model = "bedrock/amazon.titan-embed-text-v1"
|
||||
|
||||
with patch(
|
||||
|
|
@ -182,7 +178,6 @@ def test_bedrock_embedding_with_sigv4():
|
|||
|
||||
def test_bedrock_titan_v2_encoding_format_float():
|
||||
"""Test amazon.titan-embed-text-v2:0 with encoding_format=float parameter"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
model = "bedrock/amazon.titan-embed-text-v2:0"
|
||||
|
|
@ -220,7 +215,6 @@ def test_bedrock_titan_v2_encoding_format_float():
|
|||
|
||||
def test_bedrock_titan_v2_encoding_format_base64():
|
||||
"""Test amazon.titan-embed-text-v2:0 with encoding_format=base64 parameter (maps to binary)"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
model = "bedrock/amazon.titan-embed-text-v2:0"
|
||||
|
|
@ -260,7 +254,6 @@ def test_bedrock_titan_v2_encoding_format_base64():
|
|||
|
||||
def test_twelvelabs_input_type_parameter_mapping():
|
||||
"""Test that input_type parameter is correctly mapped to inputType for TwelveLabs models"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
model = "bedrock/twelvelabs.marengo-embed-2-7-v1:0"
|
||||
|
|
@ -300,7 +293,6 @@ def test_twelvelabs_input_type_parameter_mapping():
|
|||
|
||||
def test_twelvelabs_input_type_parameter_mapping_async_invoke():
|
||||
"""Test that input_type parameter is correctly mapped to inputType for TwelveLabs async invoke models"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
model = "bedrock/async_invoke/twelvelabs.marengo-embed-2-7-v1:0"
|
||||
|
|
@ -343,7 +335,6 @@ def test_twelvelabs_input_type_parameter_mapping_async_invoke():
|
|||
|
||||
def test_twelvelabs_missing_input_type_error():
|
||||
"""Test that missing input_type parameter defaults to 'text' for TwelveLabs models"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
|
||||
|
|
@ -422,7 +413,6 @@ def test_bedrock_embedding_header_forwarding(model, embed_response):
|
|||
|
||||
Relevant Issue: https://github.com/BerriAI/litellm/pull/16042
|
||||
"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
|
||||
|
|
@ -489,7 +479,6 @@ def test_bedrock_embedding_extra_headers_and_headers_merge():
|
|||
This ensures that headers from kwargs (forwarded by proxy) and extra_headers
|
||||
(passed explicitly) are both included in the final headers sent to the provider.
|
||||
"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
model = "bedrock/amazon.titan-embed-text-v1"
|
||||
|
|
@ -557,7 +546,6 @@ def test_bedrock_cohere_v4_embedding_response_parsing():
|
|||
Test parsing of Bedrock Cohere v4 embedding response which returns a dictionary of embeddings
|
||||
keyed by type (e.g. 'float', 'int8') instead of a direct list.
|
||||
"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
model = "bedrock/cohere.embed-v4:0"
|
||||
|
|
@ -617,7 +605,6 @@ def test_bedrock_embedding_custom_headers_with_iam_role_and_custom_api_base():
|
|||
|
||||
Relevant Issue: Custom headers not forwarded with IAM roles + custom api_base
|
||||
"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
|
||||
# Simulate IAM role credentials with session token
|
||||
|
|
@ -734,7 +721,6 @@ async def test_bedrock_embedding_custom_headers_with_iam_role_and_custom_api_bas
|
|||
This is the async version of the test above, verifying the fix works for both
|
||||
sync and async embedding calls.
|
||||
"""
|
||||
litellm.set_verbose = True
|
||||
client = AsyncHTTPHandler()
|
||||
|
||||
# Simulate IAM role credentials with session token
|
||||
|
|
@ -977,7 +963,6 @@ def test_bedrock_cohere_embedding_types_wrapped_as_list(
|
|||
Malformed input request: #/embedding_types: expected type: JSONArray, found: String
|
||||
when `encoding_format` is passed as a string.
|
||||
"""
|
||||
litellm.set_verbose = True
|
||||
client = HTTPHandler()
|
||||
model = "bedrock/cohere.embed-multilingual-v3"
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ mock_image_response = {"images": ["base64_encoded_image_data"], "error": None}
|
|||
class TestBedrockImageGeneration:
|
||||
def test_image_generation_with_api_key_bearer_token(self):
|
||||
"""Test image generation with bearer token authentication"""
|
||||
litellm.set_verbose = True
|
||||
test_api_key = "test-bearer-token-12345"
|
||||
model = "bedrock/stability.sd3-large-v1:0"
|
||||
prompt = "A cute baby sea otter"
|
||||
|
|
@ -53,7 +52,6 @@ class TestBedrockImageGeneration:
|
|||
|
||||
def test_image_generation_with_env_variable_bearer_token(self, monkeypatch):
|
||||
"""Test image generation with bearer token from environment variable"""
|
||||
litellm.set_verbose = True
|
||||
test_api_key = "env-bearer-token-12345"
|
||||
model = "bedrock/stability.sd3-large-v1:0"
|
||||
prompt = "A cute baby sea otter"
|
||||
|
|
@ -90,7 +88,6 @@ class TestBedrockImageGeneration:
|
|||
@pytest.mark.asyncio
|
||||
async def test_async_image_generation_with_bearer_token(self):
|
||||
"""Test async image generation with bearer token authentication"""
|
||||
litellm.set_verbose = True
|
||||
test_api_key = "async-bearer-token-12345"
|
||||
model = "bedrock/stability.sd3-large-v1:0"
|
||||
prompt = "A cute baby sea otter"
|
||||
|
|
@ -125,7 +122,6 @@ class TestBedrockImageGeneration:
|
|||
|
||||
def test_image_generation_with_sigv4(self):
|
||||
"""Test image generation falls back to SigV4 auth when no bearer token is provided"""
|
||||
litellm.set_verbose = True
|
||||
model = "bedrock/stability.sd3-large-v1:0"
|
||||
prompt = "A cute baby sea otter"
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue